Skip to content
Draft
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
56 changes: 56 additions & 0 deletions src/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ import {
getWorkflowItemActivityArchiveItemId,
MAX_LIVE_WORKFLOW_ITEM_ACTIVITY_EVENTS,
} from '@/shared/workflow/activity';
import {
isDependencyResolved,
isItemBlocked,
} from '@/shared/workflow/dependency-utils';
import { shouldScheduleItemAssignmentTask } from '@/shared/workflow/item-assignment';

if (started) {
Expand Down Expand Up @@ -647,6 +651,57 @@ void app.whenReady().then(async () => {
}
}

/**
* Detects dependency-resolution transitions and nudges newly unblocked item
* assignees through the runtime.
*/
async function reconcileDependencies(previous: unknown, next: unknown): Promise<void> {
if (!runtimeController || !isWorkflowSnapshotLike(previous) || !isWorkflowSnapshotLike(next)) {
return;
}

const previousItemsById = new Map(previous.items.map((item) => [item.id, item] as const));
const notifiedItemIds = new Set<string>();

for (const item of next.items) {
const previousItem = previousItemsById.get(item.id);

if (!previousItem || isDependencyResolved(previousItem) || !isDependencyResolved(item)) {
continue;
}

for (const dependentItem of next.items) {
if (
!dependentItem.dependsOn?.includes(item.id) ||
!dependentItem.primaryAgentId ||
notifiedItemIds.has(dependentItem.id)
) {
continue;
}

const previousDependentItem = previousItemsById.get(dependentItem.id);

if (
!previousDependentItem ||
!isItemBlocked(previousDependentItem, previous.items) ||
isItemBlocked(dependentItem, next.items)
) {
continue;
}

notifiedItemIds.add(dependentItem.id);
await runtimeController.postSystemMessage(
dependentItem.primaryAgentId,
[
`Dependency resolved for work item "${dependentItem.title}" (id: ${dependentItem.id}).`,
`"${item.title}" reached ${item.status}, so all dependencies are now satisfied.`,
'You can resume work when ready.',
].join(' '),
).catch(() => {});
}
}
}

/**
* Periodic sweep: for every item assigned to an agent and still in a
* lane that should keep an assignment task,
Expand Down Expand Up @@ -719,6 +774,7 @@ void app.whenReady().then(async () => {

const previous = await stores.workflow.get('snapshot');
await reconcileAssignments(previous, value);
await reconcileDependencies(previous, value);
if (isWorkflowSnapshotLike(value)) {
await compactWorkflowActivity(value);
}
Expand Down
175 changes: 175 additions & 0 deletions src/electron/main/agent-actions/handlers/items.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest';

import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity';

import { itemTools } from './items';
import type { WorkflowSnapshot } from './snapshot';
import type { ToolServices } from './types';

function createSnapshot(): WorkflowSnapshot {
return {
items: [
{
activity: createWorkflowItemActivitySummary({ totalEventCount: 0 }),
artifactFolderName: 'item-1',
brief: 'Blocked item',
createdAt: 1,
id: 'item-1',
primaryAgentId: 'agent-1',
projectId: 'project-1',
scheduledTaskId: null,
sortOrder: 0,
status: 'ready',
tasks: [],
title: 'Blocked item',
updatedAt: 1,
workProducts: [],
workflowEvents: [],
},
{
activity: createWorkflowItemActivitySummary({ totalEventCount: 0 }),
artifactFolderName: 'item-2',
brief: 'Dependency item',
createdAt: 2,
id: 'item-2',
primaryAgentId: null,
projectId: 'project-1',
scheduledTaskId: null,
sortOrder: 1,
status: 'active',
tasks: [],
title: 'Dependency item',
updatedAt: 2,
workProducts: [],
workflowEvents: [],
},
{
activity: createWorkflowItemActivitySummary({ totalEventCount: 0 }),
artifactFolderName: 'item-3',
brief: 'Third item',
createdAt: 3,
dependsOn: ['item-1'],
id: 'item-3',
primaryAgentId: null,
projectId: 'project-1',
scheduledTaskId: null,
sortOrder: 2,
status: 'inbox',
tasks: [],
title: 'Third item',
updatedAt: 3,
workProducts: [],
workflowEvents: [],
},
],
projects: [
{
color: '#000000',
createdAt: 1,
description: 'Project',
id: 'project-1',
name: 'Project',
rootPath: null,
updatedAt: 1,
},
],
selectedItemId: null,
selectedProjectFilter: 'all',
selectedProjectId: 'project-1',
selectedProjectView: 'board',
};
}

function createServices(snapshot: WorkflowSnapshot): ToolServices & { getSnapshot: () => WorkflowSnapshot } {
let currentSnapshot = structuredClone(snapshot);

return {
agentContext: {
agentId: 'agent-1',
agentName: 'Navigator',
ipcContainerDir: '/workspace/extra/dune',
ipcHostDir: '/tmp/dune',
projectId: 'project-1',
},
getRuntimeController: () => ({
getSnapshot: () => ({
agents: [
{
definition: { archetype: 'worker', responsibilities: [] },
id: 'agent-1',
name: 'Navigator',
projectId: 'project-1',
status: 'ready',
updatedAt: 1,
},
],
}),
}) as never,
getSnapshot: () => currentSnapshot,
onWorkflowChanged: () => undefined,
workflowStore: {
delete: async () => undefined,
get: async <T,>(key: string) => (key === 'snapshot' ? structuredClone(currentSnapshot) as T : null),
keys: async () => ['snapshot'],
set: async <T,>(key: string, value: T) => {
if (key === 'snapshot') {
currentSnapshot = structuredClone(value as WorkflowSnapshot);
}
},
},
};
}

describe('item dependency handlers', () => {
it('adds and removes dependencies on work items', async () => {
const services = createServices(createSnapshot());
const addDependency = itemTools.find((tool) => tool.definition.name === 'workflow.items.add_dependency')!.handler;
const removeDependency = itemTools.find((tool) => tool.definition.name === 'workflow.items.remove_dependency')!.handler;

await addDependency(services, {
dependsOnId: 'item-2',
itemId: 'item-1',
});

let item = services.getSnapshot().items.find((candidate) => candidate.id === 'item-1');
expect(item?.dependsOn).toEqual(['item-2']);
expect(item?.workflowEvents[0]?.description).toBe('Added dependency on "Dependency item".');

await removeDependency(services, {
dependsOnId: 'item-2',
itemId: 'item-1',
});

item = services.getSnapshot().items.find((candidate) => candidate.id === 'item-1');
expect(item?.dependsOn).toBeUndefined();
expect(item?.workflowEvents[0]?.description).toBe('Removed dependency on "Dependency item".');
});

it('rejects circular dependency updates', async () => {
const services = createServices(createSnapshot());
const addDependency = itemTools.find((tool) => tool.definition.name === 'workflow.items.add_dependency')!.handler;

await expect(addDependency(services, {
dependsOnId: 'item-3',
itemId: 'item-1',
})).rejects.toThrow('Cannot create a circular dependency.');
});

it('rejects moves to active while dependencies are unresolved', async () => {
const services = createServices(createSnapshot());
const addDependency = itemTools.find((tool) => tool.definition.name === 'workflow.items.add_dependency')!.handler;
const moveItem = itemTools.find((tool) => tool.definition.name === 'workflow.items.move')!.handler;

await addDependency(services, {
dependsOnId: 'item-2',
itemId: 'item-1',
});

await expect(moveItem(services, {
itemId: 'item-1',
status: 'active',
})).rejects.toThrow(
'Cannot move item to active: it has unresolved dependencies. All dependencies must reach done or acceptance first.',
);
});
});
113 changes: 112 additions & 1 deletion src/electron/main/agent-actions/handlers/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { createId } from '@/shared/id';
import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity';
import { createDefaultTasks } from '@/shared/workflow/default-tasks';
import { createArtifactFolderName } from '@/shared/workflow/project-artifacts';
import {
hasCircularDependency,
normalizeDependencyIds,
} from '@/shared/workflow/dependency-utils';
import { ensureProjectArtifactFolder } from '@/electron/main/workflow/project-artifacts';

import {
Expand Down Expand Up @@ -212,6 +216,113 @@ export const itemTools: RegisteredTool[] = [
return { item: presentItem(snapshot, item) };
},
},
{
definition: {
description: 'Add a dependency to a Dune work item.',
inputSchema: objectSchema(
{
dependsOnId: stringSchema,
itemId: stringSchema,
},
['itemId', 'dependsOnId'],
),
name: 'workflow.items.add_dependency',
},
handler: async ({ agentContext, onWorkflowChanged, workflowStore }, args) => {
const snapshot = await readWorkflowSnapshot(workflowStore);
const item = findItem(snapshot, requireString(args.itemId, 'itemId'));
const dependency = findItem(snapshot, requireString(args.dependsOnId, 'dependsOnId'));

if (item.projectId !== dependency.projectId) {
throw new ToolHandlerError(
'validation-error',
'Dependencies must point to items in the same project.',
);
}

if (item.id === dependency.id) {
throw new ToolHandlerError(
'validation-error',
'A work item cannot depend on itself.',
);
}

const currentDependencyIds = normalizeDependencyIds(item.dependsOn) ?? [];

if (currentDependencyIds.includes(dependency.id)) {
return { item: presentItem(snapshot, item) };
}

const nextDependencyIds = [...currentDependencyIds, dependency.id];

if (hasCircularDependency(item.id, nextDependencyIds, snapshot.items)) {
throw new ToolHandlerError(
'validation-error',
'Cannot create a circular dependency.',
);
}

const now = Date.now();
item.dependsOn = nextDependencyIds;
item.updatedAt = now;
prependWorkflowEvents(item, [
createWorkflowEvent('item', `Added dependency on "${dependency.title}".`, now, agentContext.agentName),
]);
touchProject(snapshot, item.projectId, now);

await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged);
return { item: presentItem(snapshot, item) };
},
},
{
definition: {
description: 'Remove a dependency from a Dune work item.',
inputSchema: objectSchema(
{
dependsOnId: stringSchema,
itemId: stringSchema,
},
['itemId', 'dependsOnId'],
),
name: 'workflow.items.remove_dependency',
},
handler: async ({ agentContext, onWorkflowChanged, workflowStore }, args) => {
const snapshot = await readWorkflowSnapshot(workflowStore);
const item = findItem(snapshot, requireString(args.itemId, 'itemId'));
const dependencyId = requireString(args.dependsOnId, 'dependsOnId');
const currentDependencyIds = normalizeDependencyIds(item.dependsOn) ?? [];

if (!currentDependencyIds.includes(dependencyId)) {
return { item: presentItem(snapshot, item) };
}

const dependency = snapshot.items.find((candidate) => candidate.id === dependencyId) ?? null;
const now = Date.now();
const nextDependencyIds = currentDependencyIds.filter((currentId) => currentId !== dependencyId);

if (nextDependencyIds.length > 0) {
item.dependsOn = nextDependencyIds;
} else {
delete item.dependsOn;
}

item.updatedAt = now;
prependWorkflowEvents(item, [
createWorkflowEvent(
'item',
dependency
? `Removed dependency on "${dependency.title}".`
: `Removed dependency on "${dependencyId}".`,
now,
agentContext.agentName,
),
]);
touchProject(snapshot, item.projectId, now);

await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged);
return { item: presentItem(snapshot, item) };
},
},
{
definition: {
description: 'Move a Dune work item to a new status.',
Expand Down Expand Up @@ -253,7 +364,7 @@ export const itemTools: RegisteredTool[] = [
const index = Math.max(0, Math.min(rawIndex, destinationItems.length));
const previousStatus = item.status;

assertAgentCanMoveItem(agentContext.agentId, item, status);
assertAgentCanMoveItem(agentContext.agentId, item, status, snapshot.items);

item.status = status;
item.updatedAt = now;
Expand Down
Loading