Skip to content
Open
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
16 changes: 16 additions & 0 deletions e2e-harness/__tests__/e2e-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ import { OutroKind, RunPhase } from '@lib/wizard-session';
import type { AskQuestion, WizardSession } from '@lib/wizard-session';
import { DETECTED_WAREHOUSE_SOURCES_KEY } from '@lib/programs/warehouse-source/detect';
import { Overlay } from '@ui/tui/router';
import { TASK_OUTCOMES_KEY } from '@lib/agent/runner/sequence/orchestrator/queue';
import {
E2eRunRecorder,
abortReasonFrom,
buildE2eResult,
detectedSourcesFrom,
readReportFile,
taskOutcomesFrom,
} from '../e2e-result';
import { DEFAULT_E2E_PROFILE, decideE2eAction } from '../e2e-profile';
import type { CiState } from '../wizard-ci-driver';
Expand Down Expand Up @@ -422,6 +424,9 @@ describe('buildE2eResult', () => {
matchedSignal: 'found DATABASE_URL',
},
],
[TASK_OUTCOMES_KEY]: [
{ type: 'ai-observability', status: 'not needed', optional: true },
],
},
outroData: null,
},
Expand All @@ -447,6 +452,7 @@ describe('buildE2eResult', () => {
'runPhase',
'screenPath',
'skillsComplete',
'taskOutcomes',
'tasks',
'unansweredAsks',
].sort(),
Expand All @@ -463,6 +469,16 @@ describe('buildE2eResult', () => {
]);
});

it("reports the queue's terminal outcomes by type", () => {
expect(build().taskOutcomes).toEqual([
{ type: 'ai-observability', status: 'not needed', optional: true },
]);
});

it('has no outcomes for a linear run, which drains no queue', () => {
expect(taskOutcomesFrom({ frameworkContext: {} })).toEqual([]);
});

it('reports the sources detection found', () => {
expect(build().detectedSources).toEqual([
{
Expand Down
23 changes: 23 additions & 0 deletions e2e-harness/e2e-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
import fs from 'fs';
import path from 'path';
import { OutroKind, type WizardSession } from '@lib/wizard-session';
import {
TASK_OUTCOMES_KEY,
type TaskOutcome,
} from '@lib/agent/runner/sequence/orchestrator/queue';
import { DETECTED_WAREHOUSE_SOURCES_KEY } from '@lib/programs/warehouse-source/detect';
import type { DetectedSource } from '@lib/warehouse-sources/types';
import type { E2eDecisionReport } from './e2e-profile.js';
Expand Down Expand Up @@ -219,6 +223,20 @@ export function detectedSourcesFrom(
return Array.isArray(raw) ? (raw as DetectedSource[]) : [];
}

/**
* The drained queue's final outcomes the orchestrator wrote into
* frameworkContext. Unlike the UI `tasks` rows (label + display status, where
* done and failed both render `completed`), these carry the task `type` and
* the queue's real terminal status — the stable vocabulary e2e expectations
* assert on. Empty on linear runs, which have no queue.
*/
export function taskOutcomesFrom(
session: Pick<WizardSession, 'frameworkContext'>,
): TaskOutcome[] {
const raw = session.frameworkContext[TASK_OUTCOMES_KEY];
return Array.isArray(raw) ? (raw as TaskOutcome[]) : [];
}

/** The keys the result payload carried before the warehouse work. */
export interface E2eResultBase {
runPhase: string;
Expand Down Expand Up @@ -248,6 +266,11 @@ export function buildE2eResult(args: {
refusedAsks: recorder.refusedAsks,
notices: recorder.notices,
tasks: tasks.map((t) => ({ label: t.label, status: t.status })),
taskOutcomes: taskOutcomesFrom(session).map((t) => ({
type: t.type,
status: t.status,
optional: t.optional,
})),
detectedSources: detectedSourcesFrom(session).map((s) => ({
kind: s.kind,
label: s.label,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Below are important guidance on the harness constraints you are bound to. Follow
- If a \`bash\` command is blocked, do NOT retry it or a reworded variant — the fence is deterministic and will block it again. Change approach: inspect with \`read\`/\`grep\`, fix the \`edit\` and continue, or skip a step that is not essential. Retrying blocked commands only wastes turns.
- If you get stuck on something outside your control — a package install that keeps failing, a command you are not permitted to run, or a fix outside the scope of this integration — do NOT spiral retrying it. Note it in the setup report for the user to resolve, and move on with the rest of the work.
- A \`[YARA]\` block from the security scanner is on YOUR side — it caught a real problem in the edit you just tried (PII in a \`capture()\`, a hardcoded secret or host URL). Read the block reason, understand exactly what it flagged, and change the CODE to comply — e.g. a PII block means move that field off the event and onto the person via \`identify()\`/\`$set\`, keeping the event itself. Retrying the same edit will just block again, and dropping the step loses the instrumentation — so fix it to satisfy the scanner, then continue.
- Call \`load_skill_menu\` once to choose the skill, then \`install_skill\`. Do not call \`load_skill_menu\` again this session.
- Call \`load_skill_menu\` once per category needed by the workflow, then \`install_skill\` with the chosen ID. Reuse a category’s menu instead of loading it again.
- Follow the skill's steps in order. Finish the SDK setup — install it, import it at the top of the module, and INITIALIZE it at the framework's entry point for every runtime the integration targets (typically both client and server) — BEFORE adding any event capture. A capture against an uninitialized SDK silently no-ops, so initialization comes first. If you're stuck and cannot install an SDK, add capture calls and add a clear note at the top of the integration report. Never guard a capture behind a runtime "if the SDK happens to be installed" check or a dynamic \`require\`; that ships an uninitialized SDK and no events fire. Do not jump ahead to the fix/revise step just to get a build passing.
- Never write a PostHog URL or token as a literal in source (e.g. 'https://us.i.posthog.com') — it is blocked. Read them from environment variables (process.env.POSTHOG_HOST, os.environ['POSTHOG_HOST'], etc.).
- To inspect or change a project's \`.env\` files, go straight to the wizard-tools MCP: \`check_env_keys\` to see which keys are present, \`set_env_values\` to write them. A plain \`read\`, \`edit\`, or \`write\` of any \`.env*\` file is blocked — reach for those tools first rather than discovering the block.
Expand Down Expand Up @@ -192,7 +192,7 @@ Below are important guidance on the harness constraints you are bound to. Follow
- If a \`bash\` command is blocked, do NOT retry it or a reworded variant — the fence is deterministic and will block it again. Change approach: inspect with \`read\`/\`grep\`, fix the \`edit\` and continue, or skip a step that is not essential. Retrying blocked commands only wastes turns.
- If you get stuck on something outside your control — a package install that keeps failing, a command you are not permitted to run, or a fix outside the scope of this integration — do NOT spiral retrying it. Note it in the setup report for the user to resolve, and move on with the rest of the work.
- A \`[YARA]\` block from the security scanner is on YOUR side — it caught a real problem in the edit you just tried (PII in a \`capture()\`, a hardcoded secret or host URL). Read the block reason, understand exactly what it flagged, and change the CODE to comply — e.g. a PII block means move that field off the event and onto the person via \`identify()\`/\`$set\`, keeping the event itself. Retrying the same edit will just block again, and dropping the step loses the instrumentation — so fix it to satisfy the scanner, then continue.
- Call \`load_skill_menu\` once to choose the skill, then \`install_skill\`. Do not call \`load_skill_menu\` again this session.
- Call \`load_skill_menu\` once per category needed by the workflow, then \`install_skill\` with the chosen ID. Reuse a category’s menu instead of loading it again.
- Follow the skill's steps in order. Finish the SDK setup — install it, import it at the top of the module, and INITIALIZE it at the framework's entry point for every runtime the integration targets (typically both client and server) — BEFORE adding any event capture. A capture against an uninitialized SDK silently no-ops, so initialization comes first. If you're stuck and cannot install an SDK, add capture calls and add a clear note at the top of the integration report. Never guard a capture behind a runtime "if the SDK happens to be installed" check or a dynamic \`require\`; that ships an uninitialized SDK and no events fire. Do not jump ahead to the fix/revise step just to get a build passing.
- Never write a PostHog URL or token as a literal in source (e.g. 'https://us.i.posthog.com') — it is blocked. Read them from environment variables (process.env.POSTHOG_HOST, os.environ['POSTHOG_HOST'], etc.).
- To inspect or change a project's \`.env\` files, go straight to the wizard-tools MCP: \`check_env_keys\` to see which keys are present, \`set_env_values\` to write them. A plain \`read\`, \`edit\`, or \`write\` of any \`.env*\` file is blocked — reach for those tools first rather than discovering the block.
Expand Down
12 changes: 9 additions & 3 deletions src/lib/agent/__tests__/agent-prompt-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,17 @@ Connect the sources.

it('marks the sink and the runner-seeded task from frontmatter', () => {
const p = parseAgentPrompt(
'---\nsink: true\nrunnerSeeded: true\n---\nx',
'---\nsink: true\nrunnerSeeded: true\noptional: true\n---\nx',
't',
);
expect(p.sink).toBe(true);
expect(p.runnerSeeded).toBe(true);
expect(p.optional).toBe(true);

const plain = parseAgentPrompt('---\nmodel: x\n---\nx', 't');
expect(plain.sink).toBe(false);
expect(plain.runnerSeeded).toBe(false);
expect(plain.optional).toBe(false);
});

it('defaults missing array fields to empty and models to undefined', () => {
Expand Down Expand Up @@ -212,6 +214,7 @@ describe('buildRegistry', () => {
seed: false,
sink: false,
runnerSeeded: false,
optional: false,
skills: [],
allowedTools: [],
disallowedTools: [],
Expand Down Expand Up @@ -243,15 +246,17 @@ describe('buildRegistry', () => {
prompt({ type: 'plan', flow: 'f', seed: true }),
prompt({ type: 'install', flow: 'f' }),
prompt({ type: 'warehouse', flow: 'f', runnerSeeded: true }),
prompt({ type: 'logs', flow: 'f', optional: true }),
prompt({ type: 'report', flow: 'f', sink: true }),
],
'f',
);

// The type still runs — it is only the planner that cannot reach it.
expect(registry.types).toEqual(['install', 'warehouse', 'report']);
expect(registry.enqueueableTypes).toEqual(['install', 'report']);
expect(registry.types).toEqual(['install', 'warehouse', 'logs', 'report']);
expect(registry.enqueueableTypes).toEqual(['install', 'logs', 'report']);
expect(registry.runnerSeededTypes).toEqual(['warehouse']);
expect(registry.optionalTypes).toEqual(['logs']);
expect(registry.sinkTypes).toEqual(['report']);
});

Expand Down Expand Up @@ -322,6 +327,7 @@ describe('resolveTask', () => {
seed: false,
sink: false,
runnerSeeded: false,
optional: false,
modelPi: 'openai/gpt-5.6-luna',
effortPi: 'low',
modelSdk: 'claude-haiku-4-5',
Expand Down
8 changes: 7 additions & 1 deletion src/lib/agent/agent-prompt-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function renderToolInventory(toolNames: readonly string[]): string {

const TASK_BASICS = `You are one step in a larger PostHog workflow made of several tasks, run as a fresh agent with no memory of the other tasks beyond the context you are given. Other tasks — before and after you — own the rest of the work, so stay strictly on your own task: do not do a neighbouring step's job, redo what an upstream handoff already did, or reach beyond what you were asked. Do only your task, then report exactly once by calling complete_task with a structured handoff: what your goal was, what you did, and what the next agent should know. When you are given context from previous steps, trust it — those agents already did their work, so do not re-verify or re-read what their handoffs tell you. Build on it and move fast. Read a file before you edit it, so your own changes do not duplicate what is already there. Work only inside this project's own directory: never read, list, or search (find, ls, grep, glob) outside it — not the OS, not other projects, not global package caches. If your task seems to need something outside this directory, it does not — skip that part and say so in your handoff rather than hunting across the filesystem. If your task does not apply to this project — there is genuinely nothing for it to do — report it with status \`not needed\` and say why, rather than marking it done.`;

const SEED_BASICS = `You are the orchestrator. Plan the work and seed the queue with enqueue_task — each call returns an id you can pass as a dependency to a later task. Give each task a short label for the UI — the action in a few words, not file names, class names, or other specifics. The last task you queue, the one that reports on the run, must depend on every other task in the queue — directly, or through a task it already depends on. You are not a task yourself: do not call complete_task and do not edit the project.`;
const SEED_BASICS = `You are the orchestrator. Plan the work and seed the queue with enqueue_task — each call names the task kind in its \`type\` field (the tool has no \`task\` field) and returns an id you can pass as a dependency to a later task. Give each task a short label for the UI — the action in a few words, not file names, class names, or other specifics. The last task you queue, the one that reports on the run, must depend on every other task in the queue — directly, or through a task it already depends on. You are not a task yourself: do not call complete_task and do not edit the project.`;

/**
* Tasks the wizard queued before the planner ran. It has to see them: they are
Expand Down Expand Up @@ -214,6 +214,8 @@ export interface AgentPrompt {
* one an agent can reach — it can neither invent the task nor forget it.
*/
runnerSeeded: boolean;
/** Marks a supplementary task: terminal failure unblocks dependents and never fails the run. */
optional: boolean;
/** Per-harness model/effort; both profiles also require gateway admission and prompt compatibility. */
modelPi?: string;
effortPi?: ThinkingLevel;
Expand Down Expand Up @@ -258,6 +260,8 @@ export interface AgentRegistry {
readonly sinkTypes: string[];
/** The types only the wizard queues, from what it detected before the run. */
readonly runnerSeededTypes: string[];
/** The types whose terminal failure must not block dependents or fail the run. */
readonly optionalTypes: string[];
/** The flow's planner, the one prompt marked `seed: true` in its frontmatter. */
readonly seed?: AgentPrompt;
get(type: string): AgentPrompt | undefined;
Expand Down Expand Up @@ -298,6 +302,7 @@ export function buildRegistry(
enqueueableTypes: tasks.filter((p) => !p.runnerSeeded).map((p) => p.type),
sinkTypes: tasks.filter((p) => p.sink).map((p) => p.type),
runnerSeededTypes: tasks.filter((p) => p.runnerSeeded).map((p) => p.type),
optionalTypes: tasks.filter((p) => p.optional).map((p) => p.type),
seed: inFlow.find((p) => p.seed),
get: (type) => byType.get(type),
};
Expand Down Expand Up @@ -391,6 +396,7 @@ export function parseAgentPrompt(
seed: fields.seed === 'true',
sink: fields.sink === 'true',
runnerSeeded: fields.runnerSeeded === 'true',
optional: fields.optional === 'true',
modelPi: str(fields.model_pi),
effortPi: effort(fields.effort_pi, 'effort_pi'),
modelSdk: str(fields.model_sdk),
Expand Down
2 changes: 1 addition & 1 deletion src/lib/agent/runner/harness/pi/orchestrator-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function createPiOrchestratorTools(
description:
'Add a task to the orchestrator queue. Use it to seed work and to enqueue follow-up work you discover. Keep tasks small and discrete.',
promptSnippet:
'enqueue_task(type, label, dependsOn, reason) — add a task to the queue; returns its id',
'enqueue_task({type, label, dependsOn, reason}) — add a task to the queue; the task type goes in the "type" field, and it returns the task id',
parameters: Type.Object({
type: Type.String({
description: `The task type. One of: ${ctx.validTypes.join(', ')}.`,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/agent/runner/harness/pi/runtime-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const DONT_SPIRAL =
'- If you get stuck on something outside your control — a package install that keeps failing, a command you are not permitted to run, or a fix outside the scope of this integration — do NOT spiral retrying it. Note it in the setup report for the user to resolve, and move on with the rest of the work.';

const SKILL_MENU =
'- Call `load_skill_menu` once to choose the skill, then `install_skill`. Do not call `load_skill_menu` again this session.';
'- Call `load_skill_menu` once per category needed by the workflow, then `install_skill` with the chosen ID. Reuse a category’s menu instead of loading it again.';

const SKILL_STEPS =
"- Follow the skill's steps in order. Finish the SDK setup — install it, import it at the top of the module, and INITIALIZE it at the framework's entry point for every runtime the integration targets (typically both client and server) — BEFORE adding any event capture. A capture against an uninitialized SDK silently no-ops, so initialization comes first. If you're stuck and cannot install an SDK, add capture calls and add a clear note at the top of the integration report. Never guard a capture behind a runtime \"if the SDK happens to be installed\" check or a dynamic `require`; that ships an uninitialized SDK and no events fire. Do not jump ahead to the fix/revise step just to get a build passing.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ describe('apply functions', () => {
expect(r.task.enqueuedBy).toBe('orchestrator');
});

it('stamps optional from the registry list, not the caller', () => {
ctx.optionalTypes = ['capture'];
const opt = applyEnqueue(ctx, { type: 'capture', reason: 'x' });
const req = applyEnqueue(ctx, { type: 'install', reason: 'x' });
expect(opt.ok && opt.task.optional).toBe(true);
expect(req.ok && !req.task.optional).toBe(true);
});

it('attributes a follow-up enqueue to the running task', () => {
const parent = store.enqueue({ type: 'init' });
ctx.currentTaskId = parent.id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ import {
QueueStore,
QUEUE_DIR_NAME,
SkipReason,
TASK_OUTCOMES_KEY,
TaskStatus,
type QueuedTask,
type TaskOutcome,
} from './queue';
import { drainQueue, type RunTask } from './executor';
import { RunMetrics } from './run-metrics';
Expand Down Expand Up @@ -700,6 +702,8 @@ export async function runOrchestrator(
// — a single planner edge is enough to pull the task back to the front of
// the drain and put its prompt in front of the code work again.
runnerSeededTypes: registry.runnerSeededTypes,
// Optionality comes from the task's frontmatter, never from the enqueue call.
optionalTypes: registry.optionalTypes,
currentTaskId,
});

Expand Down Expand Up @@ -1077,6 +1081,12 @@ export async function runOrchestrator(
try {
await drainQueue(store, runTask);
} finally {
// The queue file is wiped below; the e2e harness reads outcomes from here.
session.frameworkContext[TASK_OUTCOMES_KEY] = store.list().map((t) => ({
type: t.type,
status: t.status,
optional: t.optional === true,
})) satisfies TaskOutcome[];
try {
if (referenceSkillId && referenceInstallPath) {
promoteReferenceSkill(
Expand Down
8 changes: 8 additions & 0 deletions src/lib/agent/runner/sequence/orchestrator/queue-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ export interface OrchestratorToolsContext {
* {@link seededDepViolations}.
*/
runnerSeededTypes?: readonly string[];
/**
* Types marked `optional: true` in their frontmatter. Enqueue stamps the flag
* from this list — the task's definition decides, never the enqueuing agent —
* so terminal failure of such a task unblocks dependents and never fails the
* run.
*/
optionalTypes?: readonly string[];
/**
* The id of the task this tool server is bound to. Each task agent gets its
* own wizard-tools server, so attribution holds when independent tasks run
Expand Down Expand Up @@ -311,6 +318,7 @@ export function applyEnqueue(
inputs: args.inputs ?? {},
dependsOn: args.dependsOn ?? [],
model: args.model,
optional: (ctx.optionalTypes ?? []).includes(args.type) || undefined,
enqueuedBy: ctx.currentTaskId ?? 'orchestrator',
});
return { ok: true, task };
Expand Down
Loading
Loading