feat(FN-2617): merge fusion/fn-2617 (auto-resolved)

- test(FN-2617): stabilize workflow remediation timeout under full suite
- test(FN-2617): complete Step 5 — add openclaw runtime resolution coverage
- feat(FN-2617): complete Step 4 — route step sessions through runtime resolution
- fix(FN-2617): thread runtimeHint through merger rebase push flow
- feat(FN-2617): complete Step 3 — wire runtimeHint across engine subsystems
- feat(FN-2617): complete Step 2 — thread runtimeHint in executor paths
- feat(FN-2617): complete Step 1 — add runtimeHint extraction helper
This commit is contained in:
Fusion
2026-04-26 16:14:35 -07:00
committed by gsxdsm
parent 47514942e1
commit a24f50ba02
15 changed files with 246 additions and 23 deletions

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { extractRuntimeHint } from "../agent-session-helpers.js";
describe("extractRuntimeHint", () => {
it("returns undefined for undefined config", () => {
expect(extractRuntimeHint(undefined)).toBeUndefined();
});
it("returns undefined when runtimeHint key is missing", () => {
expect(extractRuntimeHint({})).toBeUndefined();
});
it("returns normalized runtime hint when configured", () => {
expect(extractRuntimeHint({ runtimeHint: " openclaw " })).toBe("openclaw");
});
it("returns undefined for whitespace-only runtimeHint", () => {
expect(extractRuntimeHint({ runtimeHint: " " })).toBeUndefined();
});
it("returns undefined for non-string runtimeHint", () => {
expect(extractRuntimeHint({ runtimeHint: 42 })).toBeUndefined();
});
});

View File

@@ -70,6 +70,10 @@ vi.mock("../agent-session-helpers.js", async () => {
wasConfigured: false,
};
},
extractRuntimeHint: (runtimeConfig: Record<string, unknown> | undefined) => {
const hint = runtimeConfig?.runtimeHint;
return typeof hint === "string" && hint.trim().length > 0 ? hint.trim() : undefined;
},
};
});
vi.mock("../worktree-names.js", async () => {
@@ -8243,7 +8247,7 @@ describe("Workflow Steps Execution", () => {
vi.useRealTimers();
await rm(tempRoot, { recursive: true, force: true });
});
}, 15_000);
it("skips script-mode step when scriptName is missing", async () => {
const store = createMockStore();

View File

@@ -42,6 +42,10 @@ vi.mock("../agent-session-helpers.js", async () => {
wasConfigured: false,
};
},
extractRuntimeHint: (runtimeConfig: Record<string, unknown> | undefined) => {
const hint = runtimeConfig?.runtimeHint;
return typeof hint === "string" && hint.trim().length > 0 ? hint.trim() : undefined;
},
};
});
vi.mock("node:child_process", () => {

View File

@@ -244,6 +244,19 @@ describe("runtime-resolution", () => {
// Should return the matching runtime
expect(result.runtime.id).toBe("unique-id");
});
it("should resolve the openclaw runtime when registered", async () => {
const openclawRuntime = createMockPluginRuntime("openclaw", "OpenClaw Runtime");
mockPluginRunner.getRuntimeById.mockReturnValue({
pluginId: "fusion-plugin-openclaw-runtime",
runtime: openclawRuntime,
});
const result = await resolveRuntime(createContext("executor", "openclaw"));
expect(result.runtimeId).toBe("openclaw");
expect(result.wasConfigured).toBe(true);
});
});
describe("fallback behavior", () => {
@@ -258,6 +271,15 @@ describe("runtime-resolution", () => {
expect(result.wasConfigured).toBe(false);
});
it("should fall back to pi when openclaw runtime is not registered", async () => {
mockPluginRunner.getRuntimeById.mockReturnValue(undefined);
const result = await resolveRuntime(createContext("executor", "openclaw"));
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
});
it("should fall back to pi when runtime factory throws", async () => {
const mockRuntime: PluginRuntimeRegistration = {
metadata: {

View File

@@ -269,5 +269,36 @@ describe("Runtime Selection Regression Tests", () => {
expect(result.runtimeId).toBe("code-interpreter");
expect(result.wasConfigured).toBe(true);
});
it("should route runtimeHint=openclaw to the openclaw runtime", async () => {
mockResolveRuntime.mockResolvedValue({
runtime: {
id: "openclaw",
name: "OpenClaw Runtime",
createSession: async () => ({
session: {
model: { provider: "openclaw", id: "openclaw-agent" },
},
}),
promptWithFallback: vi.fn(),
describeModel: () => "openclaw/openclaw-agent",
},
wasConfigured: true,
runtimeId: "openclaw",
});
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
const result = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: {} as any,
runtimeHint: "openclaw",
cwd: "/test/path",
systemPrompt: "Test prompt",
});
expect(result.runtimeId).toBe("openclaw");
expect(result.wasConfigured).toBe(true);
});
});
});

View File

@@ -546,6 +546,25 @@ vi.mock("../pi.js", () => ({
compactSessionContext: vi.fn(),
}));
vi.mock("../agent-session-helpers.js", async () => {
const pi = await import("../pi.js");
return {
createResolvedAgentSession: vi.fn(async (options: any) => {
const result = await pi.createFnAgent(options);
return {
session: result.session,
sessionFile: result.sessionFile,
runtimeId: "pi",
wasConfigured: false,
};
}),
promptWithAutoRetry: vi.fn(async (session: any, prompt: string, options?: unknown) =>
pi.promptWithFallback(session, prompt, options as any),
),
describeAgentModel: vi.fn(async (session: any) => pi.describeModel(session)),
};
});
// Mock logger
vi.mock("../logger.js", () => {
const createMockLogger = () => ({

View File

@@ -1075,7 +1075,7 @@ export class HeartbeatMonitor {
// Lazy-load promptWithFallback
const { promptWithFallback } = await import("./pi.js");
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const { createResolvedAgentSession, extractRuntimeHint } = await import("./agent-session-helpers.js");
const { buildSessionSkillContextSync } = await import("./session-skill-context.js");
// Build tools with task creation tracking and run context for mutation correlation
@@ -1153,6 +1153,7 @@ export class HeartbeatMonitor {
// Create agent session
const { session } = await createResolvedAgentSession({
sessionPurpose: "heartbeat",
runtimeHint: extractRuntimeHint(agent.runtimeConfig),
pluginRunner: this.pluginRunner,
cwd: rootDir,
systemPrompt,

View File

@@ -42,6 +42,24 @@ export interface ResolvedSessionResult {
wasConfigured: boolean;
}
/**
* Extract runtime hint from untyped runtimeConfig payload.
*
* @param runtimeConfig - Agent/task runtime configuration
* @returns normalized runtime hint or undefined when missing/invalid
*/
export function extractRuntimeHint(
runtimeConfig: Record<string, unknown> | undefined,
): string | undefined {
const hint = runtimeConfig?.runtimeHint;
if (typeof hint !== "string") {
return undefined;
}
const normalizedHint = hint.trim();
return normalizedHint.length > 0 ? normalizedHint : undefined;
}
/**
* Create an agent session using runtime resolution.
*

View File

@@ -11,7 +11,7 @@ import { findWorktreeUser } from "./merger.js";
import { generateWorktreeName, slugify } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
import { createResolvedAgentSession } from "./agent-session-helpers.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
@@ -1559,6 +1559,11 @@ export class TaskExecutor {
// ── Step-Session Path ──────────────────────────────────────────
executorLog.log(`${task.id}: using step-session mode (maxParallel=${settings.maxParallelSteps ?? 2})`);
const stepSessionAgent = detail.assignedAgentId && this.options.agentStore
? await this.options.agentStore.getAgent(detail.assignedAgentId).catch(() => null)
: null;
const stepSessionRuntimeHint = extractRuntimeHint(stepSessionAgent?.runtimeConfig);
const stepExecutor = new StepSessionExecutor({
store: this.store,
taskDetail: detail,
@@ -1568,6 +1573,7 @@ export class TaskExecutor {
semaphore: this.options.semaphore,
stuckTaskDetector: this.options.stuckTaskDetector,
pluginRunner: this.options.pluginRunner,
runtimeHint: stepSessionRuntimeHint,
// Pass skill selection context from the main executor session
skillSelection: skillContext.skillSelectionContext,
// Pass agentStore and messageStore for delegation and messaging tools
@@ -1825,6 +1831,7 @@ export class TaskExecutor {
const assignedAgent = assignedAgentId && this.options.agentStore
? await this.options.agentStore.getAgent(assignedAgentId).catch(() => null)
: null;
const executorRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
// Log fast mode status
if (executionMode === "fast") {
@@ -1918,6 +1925,7 @@ export class TaskExecutor {
// eslint-disable-next-line prefer-const
let { session, sessionFile } = await createResolvedAgentSession({
sessionPurpose: "executor",
runtimeHint: executorRuntimeHint,
pluginRunner: this.options.pluginRunner,
cwd: worktreePath,
systemPrompt: executorSystemPrompt,
@@ -2166,6 +2174,7 @@ export class TaskExecutor {
const { session: retrySession, sessionFile: retrySessionFile } = await createResolvedAgentSession({
sessionPurpose: "executor",
runtimeHint: executorRuntimeHint,
pluginRunner: this.options.pluginRunner,
cwd: worktreePath,
systemPrompt: executorSystemPrompt,
@@ -3809,8 +3818,14 @@ and show an appropriate message to the user.\`
projectRootDir: this.rootDir,
});
const workflowAgent = task.assignedAgentId && this.options.agentStore
? await this.options.agentStore.getAgent(task.assignedAgentId).catch(() => null)
: null;
const workflowRuntimeHint = extractRuntimeHint(workflowAgent?.runtimeConfig);
const { session } = await createResolvedAgentSession({
sessionPurpose: "executor",
runtimeHint: workflowRuntimeHint,
pluginRunner: this.options.pluginRunner,
cwd: worktreePath,
systemPrompt: stepSystemPrompt,
@@ -4855,10 +4870,16 @@ and show an appropriate message to the user.\`
sessionPurpose: "executor",
projectRootDir: this.rootDir,
});
const parentAgent = childTask.assignedAgentId
? await this.options.agentStore.getAgent(childTask.assignedAgentId).catch(() => null)
: null;
const childRuntimeHint = extractRuntimeHint(agent.runtimeConfig)
?? extractRuntimeHint(parentAgent?.runtimeConfig);
// Create child agent session
const { session: childSession } = await createResolvedAgentSession({
sessionPurpose: "executor",
runtimeHint: childRuntimeHint,
pluginRunner: this.options.pluginRunner,
cwd: childWorktreePath,
systemPrompt: childSystemPrompt,

View File

@@ -98,7 +98,7 @@ import { join } from "node:path";
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core";
import { resolveAgentPrompt } from "@fusion/core";
import { describeModel, promptWithFallback } from "./pi.js";
import { createResolvedAgentSession } from "./agent-session-helpers.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import type { WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
@@ -800,12 +800,13 @@ async function attemptInMergeVerificationFix(
// Build skill selection context
let skillContext = undefined;
let taskForSkillContext: Awaited<ReturnType<typeof store.getTask>> | null = null;
if (options.agentStore) {
try {
const task = await store.getTask(taskId);
taskForSkillContext = await store.getTask(taskId);
skillContext = await buildSessionSkillContext({
agentStore: options.agentStore,
task,
task: taskForSkillContext,
sessionPurpose: "merger",
projectRootDir: rootDir,
});
@@ -816,8 +817,17 @@ async function attemptInMergeVerificationFix(
// Create the fix agent session
throwIfAborted(options.signal, taskId);
const assignedAgentId = taskForSkillContext?.assignedAgentId?.trim();
const agentStoreWithGetAgent = options.agentStore && typeof (options.agentStore as { getAgent?: unknown }).getAgent === "function"
? options.agentStore
: null;
const assignedAgent = assignedAgentId && agentStoreWithGetAgent
? await agentStoreWithGetAgent.getAgent(assignedAgentId).catch(() => null)
: null;
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
runtimeHint: mergerRuntimeHint,
pluginRunner: options.pluginRunner,
cwd: rootDir, // Runs on the main branch in the project root
systemPrompt: `You are a verification fix agent running during a merge on the main branch.
@@ -1665,7 +1675,12 @@ async function resolveComplexRebaseConflictsWithAi(
taskId: string,
settings: Settings,
conflictedFiles: string[],
options?: { onAgentText?: (delta: string) => void; pluginRunner?: import("./plugin-runner.js").PluginRunner; signal?: AbortSignal },
options?: {
onAgentText?: (delta: string) => void;
pluginRunner?: import("./plugin-runner.js").PluginRunner;
signal?: AbortSignal;
runtimeHint?: string;
},
): Promise<void> {
mergerLog.log(`${taskId}: resolving ${conflictedFiles.length} complex rebase conflict(s) with AI`);
@@ -1693,6 +1708,7 @@ You are assisting with a paused \`git pull --rebase\`.
throwIfAborted(options?.signal, taskId);
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
runtimeHint: options?.runtimeHint,
pluginRunner: options?.pluginRunner,
cwd: rootDir,
systemPrompt,
@@ -1738,7 +1754,7 @@ async function resolveRebaseConflictSet(
rootDir: string,
taskId: string,
settings: Settings,
options?: { onAgentText?: (delta: string) => void; signal?: AbortSignal },
options?: { onAgentText?: (delta: string) => void; signal?: AbortSignal; runtimeHint?: string },
): Promise<void> {
const conflictedFiles = await getConflictedFiles(rootDir);
if (conflictedFiles.length === 0) return;
@@ -1781,7 +1797,7 @@ async function pullWithRebaseAndResolveConflicts(
settings: Settings,
remote: string,
branch: string,
options?: { onAgentText?: (delta: string) => void; signal?: AbortSignal },
options?: { onAgentText?: (delta: string) => void; signal?: AbortSignal; runtimeHint?: string },
): Promise<void> {
const pullCommand = `git pull --rebase ${quoteArg(remote)} ${quoteArg(branch)}`;
try {
@@ -1872,7 +1888,7 @@ export async function pushToRemoteAfterMerge(
rootDir: string,
taskId: string,
settings: Settings,
options?: { onAgentText?: (delta: string) => void; signal?: AbortSignal },
options?: { onAgentText?: (delta: string) => void; signal?: AbortSignal; runtimeHint?: string },
): Promise<{ pushed: boolean; error?: string }> {
let target: { remote: string; branch: string };
@@ -2676,7 +2692,20 @@ export async function aiMergeTask(
if (settings.pushAfterMerge && settings.mergeStrategy !== "pull-request") {
try {
throwIfAborted(options.signal, taskId);
const pushResult = await pushToRemoteAfterMerge(store, rootDir, taskId, settings, options);
const pushTask = await store.getTask(taskId).catch(() => null);
const pushAssignedAgentId = pushTask?.assignedAgentId?.trim();
const pushAgentStoreWithGetAgent = options.agentStore && typeof (options.agentStore as { getAgent?: unknown }).getAgent === "function"
? options.agentStore
: null;
const pushAssignedAgent = pushAssignedAgentId && pushAgentStoreWithGetAgent
? await pushAgentStoreWithGetAgent.getAgent(pushAssignedAgentId).catch(() => null)
: null;
const pushRuntimeHint = extractRuntimeHint(pushAssignedAgent?.runtimeConfig);
const pushResult = await pushToRemoteAfterMerge(store, rootDir, taskId, settings, {
onAgentText: options.onAgentText,
signal: options.signal,
runtimeHint: pushRuntimeHint,
});
if (pushResult.pushed) {
mergerLog.log(`${taskId}: pushed merged result to remote`);
} else {
@@ -3278,12 +3307,13 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
// Build skill selection context (assigned agent skills take precedence over role fallback)
let skillContext = undefined;
let taskForSkillContext: Awaited<ReturnType<typeof store.getTask>> | null = null;
if (options.agentStore) {
try {
const task = await store.getTask(taskId);
taskForSkillContext = await store.getTask(taskId);
skillContext = await buildSessionSkillContext({
agentStore: options.agentStore,
task,
task: taskForSkillContext,
sessionPurpose: "merger",
projectRootDir: rootDir,
});
@@ -3292,8 +3322,18 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
}
}
const assignedAgentId = taskForSkillContext?.assignedAgentId?.trim();
const agentStoreWithGetAgent = options.agentStore && typeof (options.agentStore as { getAgent?: unknown }).getAgent === "function"
? options.agentStore
: null;
const assignedAgent = assignedAgentId && agentStoreWithGetAgent
? await agentStoreWithGetAgent.getAgent(assignedAgentId).catch(() => null)
: null;
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
runtimeHint: mergerRuntimeHint,
pluginRunner: options.pluginRunner,
cwd: rootDir,
systemPrompt: mergerSystemPrompt,
@@ -3764,12 +3804,13 @@ If issues are found that need attention, describe them clearly.`;
// Build skill selection context for post-merge session
let postMergeSkillContext = undefined;
let taskForSkillContext: Awaited<ReturnType<typeof store.getTask>> | null = null;
if (mergeOptions.agentStore) {
try {
const task = await store.getTask(taskId);
taskForSkillContext = await store.getTask(taskId);
postMergeSkillContext = await buildSessionSkillContext({
agentStore: mergeOptions.agentStore,
task,
task: taskForSkillContext,
sessionPurpose: "merger",
projectRootDir: rootDir,
});
@@ -3778,8 +3819,17 @@ If issues are found that need attention, describe them clearly.`;
}
}
const assignedAgentId = taskForSkillContext?.assignedAgentId?.trim();
const agentStoreWithGetAgent = mergeOptions.agentStore && typeof (mergeOptions.agentStore as { getAgent?: unknown }).getAgent === "function"
? mergeOptions.agentStore
: null;
const assignedAgent = assignedAgentId && agentStoreWithGetAgent
? await agentStoreWithGetAgent.getAgent(assignedAgentId).catch(() => null)
: null;
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
runtimeHint: mergerRuntimeHint,
pluginRunner: mergeOptions.pluginRunner,
cwd,
systemPrompt: postMergeSystemPrompt,

View File

@@ -18,9 +18,10 @@ import type {
MissionContractAssertion,
MissionFeature,
MissionValidatorRun,
AgentStore,
} from "@fusion/core";
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
import { createResolvedAgentSession } from "./agent-session-helpers.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { createLogger } from "./logger.js";
/** Logger for the mission execution loop subsystem. */
@@ -66,6 +67,8 @@ export interface MissionExecutionLoopOptions {
maxRetryBudget?: number;
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
pluginRunner?: import("./plugin-runner.js").PluginRunner;
/** Optional agent store for resolving assigned-agent runtime hints. */
agentStore?: AgentStore;
}
export class MissionExecutionLoop extends EventEmitter {
@@ -76,6 +79,7 @@ export class MissionExecutionLoop extends EventEmitter {
private maxRetryBudget: number;
private missionAutopilot?: MissionExecutionLoopOptions["missionAutopilot"];
private pluginRunner?: MissionExecutionLoopOptions["pluginRunner"];
private agentStore?: MissionExecutionLoopOptions["agentStore"];
private activeValidations = new Set<string>(); // feature IDs currently being validated
constructor(options: MissionExecutionLoopOptions) {
@@ -86,6 +90,7 @@ export class MissionExecutionLoop extends EventEmitter {
this.maxRetryBudget = options.maxRetryBudget ?? 3;
this.missionAutopilot = options.missionAutopilot;
this.pluginRunner = options.pluginRunner;
this.agentStore = options.agentStore;
loopLog.log("MissionExecutionLoop created");
}
@@ -325,6 +330,9 @@ export class MissionExecutionLoop extends EventEmitter {
// Get task context for validation
const task = feature.taskId ? await this.taskStore.getTask(feature.taskId) : null;
const taskContext = task ? this.buildTaskContext(task) : "";
const validationRuntimeHint = task?.assignedAgentId && this.agentStore
? extractRuntimeHint((await this.agentStore.getAgent(task.assignedAgentId).catch(() => null))?.runtimeConfig)
: undefined;
let session: AgentResult | null = null;
@@ -332,6 +340,7 @@ export class MissionExecutionLoop extends EventEmitter {
// Create validation agent session
const sessionResult = await createResolvedAgentSession({
sessionPurpose: "validation",
runtimeHint: validationRuntimeHint,
pluginRunner: this.pluginRunner,
cwd: this.rootDir,
systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext),

View File

@@ -11,7 +11,7 @@
import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core";
import { buildReviewerMemoryInstructions, resolveAgentPrompt } from "@fusion/core";
import { describeModel, promptWithFallback } from "./pi.js";
import { createResolvedAgentSession } from "./agent-session-helpers.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { AgentLogger } from "./agent-logger.js";
import { reviewerLog } from "./logger.js";
@@ -364,6 +364,7 @@ export async function reviewStep(
: undefined;
const { session } = await createResolvedAgentSession({
sessionPurpose: "reviewer",
runtimeHint: extractRuntimeHint(memoryAgent?.runtimeConfig),
pluginRunner: options.pluginRunner,
cwd,
systemPrompt: reviewerSystemPrompt,

View File

@@ -256,6 +256,7 @@ export class InProcessRuntime
: undefined,
rootDir: this.config.workingDirectory,
pluginRunner: this.pluginRunner,
agentStore: this.agentStore,
})
: undefined;

View File

@@ -19,7 +19,11 @@ import { join } from "node:path";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { AgentStore, MessageStore, TaskDetail, Settings, TaskStore } from "@fusion/core";
import { createFnAgent, promptWithFallback, describeModel } from "./pi.js";
import {
createResolvedAgentSession,
describeAgentModel,
promptWithAutoRetry,
} from "./agent-session-helpers.js";
import type { SkillSelectionContext } from "./skill-resolver.js";
import { generateWorktreeName } from "./worktree-names.js";
import { AgentSemaphore } from "./concurrency.js";
@@ -82,6 +86,8 @@ export interface StepSessionExecutorOptions {
stuckTaskDetector?: StuckTaskDetector;
/** Optional plugin runner for providing plugin tools to step sessions. */
pluginRunner?: import("./plugin-runner.js").PluginRunner;
/** Optional runtime hint resolved from assigned agent runtimeConfig. */
runtimeHint?: string;
/** Callback invoked when a step starts executing. */
onStepStart?: (stepIndex: number) => void;
/** Callback invoked when a step completes (success or failure). */
@@ -902,7 +908,10 @@ export class StepSessionExecutor {
settings,
);
const createResult = await createFnAgent({
const createResult = await createResolvedAgentSession({
sessionPurpose: "executor",
runtimeHint: this.options.runtimeHint,
pluginRunner: this.options.pluginRunner,
cwd: worktreePath,
systemPrompt: `You are an AI agent executing step ${stepIndex} of task ${taskDetail.id}. Follow instructions precisely.`,
defaultProvider: executorProvider,
@@ -950,13 +959,14 @@ export class StepSessionExecutor {
this.activeSessions.set(stepIndex, handle);
stuckTaskDetector?.trackTask(trackingKey, { dispose: () => session?.dispose() }, taskDetail.id);
const sessionModel = await describeAgentModel(session);
stepExecLog.log(
`Step ${stepIndex} attempt ${attempt + 1} session created ` +
`(model=${describeModel(session)}) for task ${taskDetail.id}`,
`(model=${sessionModel}) for task ${taskDetail.id}`,
);
// Send prompt
await promptWithFallback(session, stepPrompt);
await promptWithAutoRetry(session, stepPrompt);
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
// session.prompt() resolves normally even when retries are exhausted —
@@ -990,7 +1000,7 @@ export class StepSessionExecutor {
recoveryAttempts++;
try {
stuckTaskDetector?.recordActivity(trackingKey);
await promptWithFallback(session, reducedStepPrompt);
await promptWithAutoRetry(session, reducedStepPrompt);
checkSessionError(session);
stepExecLog.log(`Step ${stepIndex} reduced-prompt recovery succeeded`);
await this.store.appendAgentLog(

View File

@@ -18,7 +18,7 @@ import type {
AgentSession,
} from "@mariozechner/pi-coding-agent";
import { describeModel, promptWithFallback } from "./pi.js";
import { createResolvedAgentSession } from "./agent-session-helpers.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
@@ -858,12 +858,18 @@ export class TriageProcessor {
),
];
const assignedAgent = task.assignedAgentId && this.options.agentStore
? await this.options.agentStore.getAgent(task.assignedAgentId).catch(() => null)
: null;
let triageRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
// Resolve per-agent custom instructions for the triage role
let triageInstructions = "";
if (this.options.agentStore) {
try {
const agents = await this.options.agentStore.listAgents({ role: "triage" });
for (const agent of agents) {
triageRuntimeHint ??= extractRuntimeHint(agent.runtimeConfig);
if (agent.instructionsText || agent.instructionsPath) {
triageInstructions = await resolveAgentInstructions(agent, this.rootDir);
break;
@@ -891,6 +897,7 @@ export class TriageProcessor {
let { session } = await createResolvedAgentSession({
sessionPurpose: "triage",
runtimeHint: triageRuntimeHint,
pluginRunner: this.options.pluginRunner,
cwd: this.rootDir,
systemPrompt: triageSystemPrompt,
@@ -1111,6 +1118,7 @@ export class TriageProcessor {
const fallbackResult = await createResolvedAgentSession({
sessionPurpose: "triage",
runtimeHint: triageRuntimeHint,
pluginRunner: this.options.pluginRunner,
cwd: this.rootDir,
systemPrompt: triageSystemPrompt,