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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ vi.mock('@utils/analytics', () => ({
}));

import { QueueStore } from '@lib/agent/runner/sequence/orchestrator/queue';
import { drainVerdict } from '@lib/agent/runner/sequence/orchestrator/orchestrator-runner';
import {
describeDrainFailure,
drainVerdict,
} from '@lib/agent/runner/sequence/orchestrator/orchestrator-runner';

describe('drainVerdict', () => {
let dir: string;
Expand Down Expand Up @@ -52,5 +55,47 @@ describe('drainVerdict', () => {
const v = drainVerdict(store.list());
expect(v.requiredFailedTypes).toEqual(['install']);
expect(v.blocked).toBe(1);
expect(v.blockedTypes).toEqual(['report']);
});

it('names a step the user accepted that never became runnable', () => {
const install = store.enqueue({ type: 'install' });
store.enqueue({
type: 'warehouse',
optional: true,
dependsOn: [install.id],
});
finish(install.id, false);

expect(drainVerdict(store.list()).blockedTypes).toEqual(['warehouse']);
});
});

describe('describeDrainFailure', () => {
it('names the failure and the steps it stopped', () => {
expect(
describeDrainFailure({
requiredFailedTypes: ['install'],
blockedTypes: ['warehouse', 'report'],
}),
).toBe('the install step failed, so the warehouse, report steps never ran');
});

it('names blocked steps when nothing failed outright', () => {
expect(
describeDrainFailure({
requiredFailedTypes: [],
blockedTypes: ['warehouse'],
}),
).toBe('the warehouse step never ran');
});

it('names the failure alone when nothing was left pending', () => {
expect(
describeDrainFailure({
requiredFailedTypes: ['report'],
blockedTypes: [],
}),
).toBe('the report step failed');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -370,19 +370,48 @@ export function drainVerdict(tasks: readonly QueuedTask[]): {
requiredFailedTypes: string[];
optionalFailedTypes: string[];
blocked: number;
blockedTypes: string[];
} {
const failed = tasks.filter((t) => t.status === TaskStatus.Failed);
const pending = tasks.filter((t) => t.status === TaskStatus.Pending);
return {
requiredFailedTypes: failed
.filter((t) => t.optional !== true)
.map((t) => t.type),
optionalFailedTypes: failed
.filter((t) => t.optional === true)
.map((t) => t.type),
blocked: tasks.filter((t) => t.status === TaskStatus.Pending).length,
blocked: pending.length,
blockedTypes: pending.map((t) => t.type),
};
}

/**
* The one-line "what went wrong" the abort message leads with.
*
* Both halves are named. A drain that ends with work still pending used to
* report only how many steps never ran, which is the least useful fact about
* them: a user who agreed to connect their data sources and then read that
* "2 steps never ran" had no way to tell whether that step was one of them.
*/
export function describeDrainFailure(verdict: {
requiredFailedTypes: string[];
blockedTypes: string[];
}): string {
const parts: string[] = [];
if (verdict.requiredFailedTypes.length > 0) {
parts.push(`the ${verdict.requiredFailedTypes.join(', ')} step failed`);
}
if (verdict.blockedTypes.length > 0) {
parts.push(
`the ${verdict.blockedTypes.join(', ')} step${
verdict.blockedTypes.length === 1 ? '' : 's'
} never ran`,
);
}
return parts.join(', so ');
}

/** How many tasks deep in the graph a task sits — 0 when it depends on nothing. */
function graphDepth(
task: QueuedTask,
Expand Down Expand Up @@ -1155,11 +1184,23 @@ export async function runOrchestrator(
// A failed optional task is exempt: reported per-task, never run-failing.
const verdict = drainVerdict(store.list());
const blocked = verdict.blocked;
// A pending task at this point never ran and never will — its dependency
// failed. No transition fires for it, so without this the step leaves no
// terminal event at all: a step the user was offered and accepted simply
// drops out of the funnel. The queue itself is left alone, because the run
// cache is already wiped by here and writing to it would recreate the folder
// the cleanup just removed.
for (const task of store.list()) {
if (task.status !== TaskStatus.Pending) continue;
analytics.wizardCapture('orchestrator task blocked', {
type: task.type,
optional: task.optional === true,
failed_types: verdict.requiredFailedTypes.join(',') || 'none',
});
}
if (verdict.requiredFailedTypes.length > 0 || blocked > 0) {
const failedTypes = verdict.requiredFailedTypes.join(', ');
const whatFailed = failedTypes
? `the ${failedTypes} step failed`
: `${blocked} steps never ran`;
const whatFailed = describeDrainFailure(verdict);
// A grant narrowed at login is the one failure cause the user can fix
// alone — lead with the fix, and only fall back to the report-a-bug line
// when trying again doesn't work.
Expand Down
Loading