Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d5c4716
chore: auto-commit agent work (76cc7f25-b5f)
jaeko44 Mar 25, 2026
2aea412
test(workflow): fix watchdog PR template assertions
jaeko44 Mar 25, 2026
4b726b1
test(workflow): align watchdog retry expectation
jaeko44 Mar 25, 2026
113b8b9
fix(workflow): wire PR watchdog delegation settings into fallback agent
jaeko44 Mar 25, 2026
e8241fb
fix(workflow): pass delegation watchdog settings through templates
jaeko44 Mar 25, 2026
0f5a06b
test(workflow): mock execFileSync in template e2e harness
jaeko44 Mar 25, 2026
5559173
fix(workflow): resolve workflow-engine rebase conflict
jaeko44 Mar 25, 2026
4a9fd0d
test(workflow): align UI port mapping regression copy
jaeko44 Mar 26, 2026
3bf3af4
test(workflow): align DAG revision UI regression copy
jaeko44 Mar 26, 2026
b5b9b90
fix(workflow): restore bosun-created template markers
jaeko44 Mar 26, 2026
4fa0855
chore(workflow): normalize EOF whitespace
jaeko44 Mar 26, 2026
ded86e4
test(workflow): restore delegated session tracking case
jaeko44 Mar 26, 2026
b4422ca
Merge branch 'main' into repair/pr437
jaeko44 Mar 26, 2026
e5eeb03
Merge branch 'main' into repair/pr437
jaeko44 Mar 26, 2026
a9de9f9
Merge remote-tracking branch 'origin/main' into HEAD
jaeko44 Mar 26, 2026
20a64d8
Merge remote-tracking branch 'origin/main' into HEAD
jaeko44 Mar 26, 2026
c15ba33
Merge branch 'main' into repair/pr437
jaeko44 Mar 26, 2026
c33854f
test(workflow): align watchdog resume expectation
jaeko44 Mar 26, 2026
e00b50c
Apply suggestions from code review
jaeko44 Mar 30, 2026
39059d6
fix: resolve merge conflicts and address review comments
Copilot Mar 30, 2026
ce852d2
Merge branch 'main' into repair/pr437
jaeko44 Mar 30, 2026
61543ea
fix(ui): add Select source/target port placeholder options to port bi…
Copilot Mar 30, 2026
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
308 changes: 304 additions & 4 deletions tests/workflow-engine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1628,6 +1628,161 @@ describe("WorkflowEngine - run history details", () => {
expect(resumedRun?.detail?.data?.prePrValidationCommand).toBe("auto");
});

it("retries a stalled non-task delegation run exactly once during interrupted-run recovery", async () => {
const wf = makeSimpleWorkflow(
[{ id: "trigger", type: "trigger.manual", label: "Start", config: {} }],
[],
{ id: "wf-stalled-delegation", name: "Stalled Delegation Workflow" },
);
engine.save(wf);

const runsDir = join(tmpDir, "runs");
const interruptedRunId = "run-stalled-delegation";

writeFileSync(
join(runsDir, "index.json"),
JSON.stringify({
runs: [
{
runId: interruptedRunId,
workflowId: wf.id,
workflowName: wf.name,
status: WorkflowStatus.PAUSED,
startedAt: 1000,
endedAt: null,
resumable: true,
},
],
}, null, 2),
"utf8",
);
writeFileSync(
join(runsDir, `${interruptedRunId}.json`),
JSON.stringify({
id: interruptedRunId,
startedAt: 1000,
endedAt: null,
data: {
_workflowId: wf.id,
_workflowName: wf.name,
delegationTarget: "template-bosun-pr-progressor",
_delegationWatchdog: {
nodeId: "handoff",
state: "delegated",
delegationType: "workflow",
taskScoped: false,
startedAt: 1000,
timeoutMs: 50,
},
},
nodeStatuses: {
trigger: NodeStatus.COMPLETED,
handoff: NodeStatus.RUNNING,
},
nodeStatusEvents: [{ nodeId: "handoff", status: NodeStatus.RUNNING, timestamp: 1000 }],
logs: [],
errors: [],
}, null, 2),
"utf8",
);
writeFileSync(join(runsDir, "_active-runs.json"), JSON.stringify([], null, 2), "utf8");

const retryRunSpy = vi.spyOn(engine, "retryRun").mockResolvedValue({
retryRunId: "retry-stalled-delegation",
mode: "from_scratch",
originalRunId: interruptedRunId,
ctx: { id: "retry-stalled-delegation" },
});

await engine.resumeInterruptedRuns();

expect(retryRunSpy).toHaveBeenCalledTimes(1);
expect(retryRunSpy).toHaveBeenCalledWith(interruptedRunId, expect.objectContaining({
mode: "from_failed",
_decisionReason: expect.stringContaining("delegation_watchdog"),
}));
const index = JSON.parse(readFileSync(join(runsDir, "index.json"), "utf8"));
const interrupted = index.runs.find((entry) => entry.runId === interruptedRunId);
expect(interrupted?.resumable).toBe(false);
expect(interrupted?.resumeResult).toBe("resumed");
});

it("marks a repeatedly stalled non-task delegation run unresumable without looping", async () => {
const wf = makeSimpleWorkflow(
[{ id: "trigger", type: "trigger.manual", label: "Start", config: {} }],
[],
{ id: "wf-stalled-delegation-loop", name: "Stalled Delegation Loop Workflow" },
);
engine.save(wf);

const runsDir = join(tmpDir, "runs");
const interruptedRunId = "run-stalled-delegation-loop";

writeFileSync(
join(runsDir, "index.json"),
JSON.stringify({
runs: [
{
runId: interruptedRunId,
workflowId: wf.id,
workflowName: wf.name,
status: WorkflowStatus.PAUSED,
startedAt: 1000,
endedAt: null,
resumable: true,
},
],
}, null, 2),
"utf8",
);
writeFileSync(
join(runsDir, `${interruptedRunId}.json`),
JSON.stringify({
id: interruptedRunId,
startedAt: 1000,
endedAt: null,
data: {
_workflowId: wf.id,
_workflowName: wf.name,
_delegationWatchdog: {
nodeId: "handoff",
state: "delegated",
delegationType: "workflow",
taskScoped: false,
startedAt: 1000,
timeoutMs: 50,
recoveryAttempted: true,
},
},
nodeStatuses: {
trigger: NodeStatus.COMPLETED,
handoff: NodeStatus.RUNNING,
},
nodeStatusEvents: [{ nodeId: "handoff", status: NodeStatus.RUNNING, timestamp: 1000 }],
logs: [],
errors: [],
}, null, 2),
"utf8",
);
writeFileSync(join(runsDir, "_active-runs.json"), JSON.stringify([], null, 2), "utf8");

const retryRunSpy = vi.spyOn(engine, "retryRun").mockResolvedValue({
retryRunId: "retry-stalled-delegation-loop",
mode: "from_scratch",
originalRunId: interruptedRunId,
ctx: { id: "retry-stalled-delegation-loop" },
});

await engine.resumeInterruptedRuns();

expect(retryRunSpy).not.toHaveBeenCalled();

const index = JSON.parse(readFileSync(join(runsDir, "index.json"), "utf8"));
const interrupted = index.runs.find((entry) => entry.runId === interruptedRunId);
expect(interrupted?.resumable).toBe(false);
expect(interrupted?.resumeResult).toContain("delegation_watchdog_exhausted");
});

it("resumes interrupted runs from_scratch when issue-advisor requests replanning", async () => {
const wf = makeSimpleWorkflow(
[{ id: "trigger", type: "trigger.manual", label: "Start", config: {} }],
Expand Down Expand Up @@ -4225,6 +4380,155 @@ describe("Session chaining - action.run_agent", () => {
},
};

const node = {
id: "delegated-session-node",
type: "action.run_agent",
config: { prompt: "Handle task via delegated workflow", failOnError: true },
};

const result = await handler.execute(node, ctx, mockEngine);
expect(result.success).toBe(true);
expect(result.delegated).toBe(true);

const tracker = getSessionTracker();
const session = tracker.getSessionById("TASK-DELEGATE-SESSION");
expect(session).toBeTruthy();
expect(session.type).toBe("task");
expect(session.status).toBe("running");
expect(session.metadata.branch).toBe("feat/backend-migration");

const messages = Array.isArray(session.messages) ? session.messages : [];
expect(messages.some((msg) => String(msg?.content || "").includes("Delegating to agent workflow"))).toBe(true);
});

it("marks stalled delegated non-task workflows retryable and recovers only once", async () => {
const handler = getNodeType("action.run_agent");
expect(handler).toBeDefined();

const ctx = new WorkflowContext({
workspaceId: "virtengine-gh",
ticketId: "GH-123",
delegationWatchdogTimeoutMs: 25,
});

const stalledRun = {
runId: "delegated-stalled-run",
status: WorkflowStatus.RUNNING,
isStuck: true,
stuckMs: 50,
};

const mockEngine = {
list: vi.fn().mockReturnValue([
{
id: "wf-non-task-delegate",
name: "Generic Delegate",
enabled: true,
metadata: { replaces: { module: "primary-agent.mjs" } },
nodes: [{ id: "trigger", type: "trigger.manual", config: {} }],
},
]),
execute: vi.fn()
.mockResolvedValueOnce({ runId: stalledRun.runId, status: WorkflowStatus.RUNNING, delegated: true })
.mockResolvedValueOnce({ runId: "delegated-retry-run", status: WorkflowStatus.COMPLETED, delegated: true, outputs: { ok: true } }),
getRunHistory: vi.fn().mockReturnValue([stalledRun]),
services: {
agentPool: {
launchEphemeralThread: vi.fn(),
},
},
};

const node = {
id: "delegated-watchdog-node",
type: "action.run_agent",
config: {
prompt: "Handle generic delegated workflow",
failOnError: false,
delegationWatchdogTimeoutMs: 25,
},
};

const result = await handler.execute(node, ctx, mockEngine);
expect(result.success).toBe(true);
expect(result.delegated).toBe(true);
expect(result.recoveredFromStall).toBe(true);
expect(result.watchdogRecovered).toBe(true);
expect(result.watchdogRetryCount).toBe(1);
expect(mockEngine.execute).toHaveBeenCalledTimes(2);
});

it("does not retry delegated non-task workflows more than once after watchdog recovery", async () => {
const handler = getNodeType("action.run_agent");
expect(handler).toBeDefined();

const ctx = new WorkflowContext({ workspaceId: "virtengine-gh" });

const mockEngine = {
list: vi.fn().mockReturnValue([
{
id: "wf-non-task-delegate-loop",
name: "Generic Delegate",
enabled: true,
metadata: { replaces: { module: "primary-agent.mjs" } },
nodes: [{ id: "trigger", type: "trigger.manual", config: {} }],
},
]),
execute: vi.fn()
.mockResolvedValueOnce({ runId: "delegated-stalled-1", status: WorkflowStatus.RUNNING, delegated: true })
.mockResolvedValueOnce({ runId: "delegated-stalled-2", status: WorkflowStatus.RUNNING, delegated: true }),
getRunHistory: vi.fn().mockReturnValue([{ runId: "delegated-stalled-2", status: WorkflowStatus.RUNNING, isStuck: true, stuckMs: 75 }]),
services: {
agentPool: {
launchEphemeralThread: vi.fn(),
},
},
};

const node = {
id: "delegated-watchdog-node-once",
type: "action.run_agent",
config: {
prompt: "Handle generic delegated workflow",
failOnError: false,
delegationWatchdogTimeoutMs: 25,
},
};

const result = await handler.execute(node, ctx, mockEngine);
expect(result.success).toBe(false);
expect(result.retryable).toBe(true);
expect(result.failureKind).toBe("stalled_delegation");
expect(result.watchdogRetryCount).toBe(1);
expect(mockEngine.execute).toHaveBeenCalledTimes(2);
});
const mockEngine = {
list: vi.fn().mockReturnValue([
{
id: "wf-backend",
name: "Backend Agent",
enabled: true,
Comment on lines +5027 to +5032
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block leaves a stray const mockEngine = { ... } outside of any it(...)/function scope, which will cause a syntax/runtime error and prevent the test suite from running. Remove the orphaned block or wrap it in the intended test case (and ensure braces/parentheses are balanced).

Copilot uses AI. Check for mistakes.
metadata: { replaces: { module: "primary-agent.mjs" } },
nodes: [
{
id: "trigger",
type: "trigger.task_assigned",
config: {
taskPattern: "backend",
filter: "task.tags?.includes('backend')",
},
},
],
},
]),
execute: vi.fn().mockResolvedValue({ errors: [] }),
services: {
agentPool: {
launchEphemeralThread: vi.fn(),
},
},
};

const node = {
id: "delegated-session-node",
type: "action.run_agent",
Expand Down Expand Up @@ -6407,7 +6711,3 @@ describe("WorkflowEngine.getTaskTraceEvents", () => {
expect(replan.reason).toBe("issue_advisor.replan_subgraph");
});
});




10 changes: 5 additions & 5 deletions tests/workflow-run-history-ui-regression.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ describe("workflow run history UI pagination", () => {
it(`${label} exposes DAG revision history in run details`, () => {
if (label !== "ui") return;
expect(source).toContain("DAG Revisions");
expect(source).toContain("graphBefore");
expect(source).toContain("graphAfter");
expect(source).toContain("Graph Before:");
expect(source).toContain("Graph After:");
});

it(`${label} exposes explicit edge port mapping controls`, () => {
Expand All @@ -54,8 +54,8 @@ describe("workflow run history UI pagination", () => {
expect(source).toContain("Source Port");
expect(source).toContain("Target Port");
expect(source).toContain("updateEdgePortMapping");
expect(source).toContain("Unknown output port");
expect(source).toContain("Unknown input port");
expect(source).toContain("Select source port");
expect(source).toContain("Select target port");
});
}

Expand All @@ -72,4 +72,4 @@ describe("workflow run history UI pagination", () => {
expect(source).not.toContain("validateEdgePortMapping");
});
}
});
});
7 changes: 7 additions & 0 deletions tests/workflow-task-lifecycle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3471,11 +3471,18 @@ describe("template-task-lifecycle", () => {
maxParallel: 5,
taskTimeoutMs: 3600000,
});

expect(result.variables.maxParallel).toBe(5);
expect(result.variables.taskTimeoutMs).toBe(3600000);
expect(result.variables.defaultSdk).toBe("auto"); // unchanged
});

it("installs delegation watchdog defaults for non-task recovery", () => {
const result = installTemplate("template-task-lifecycle", engine);
expect(result.variables.delegationWatchdogTimeoutMs).toBeGreaterThan(0);
expect(result.variables.delegationWatchdogMaxRecoveries).toBe(1);
});

it("dry-run executes without errors (trigger stops at no kanban)", async () => {
const result = installTemplate("template-task-lifecycle", engine);
const ctx = new WorkflowContext({});
Expand Down
Loading
Loading