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
9 changes: 9 additions & 0 deletions .changeset/orphan-recovery-priority-ordering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@stoneforge/smithy": patch
---

fix(smithy): orphan recovery dispatches highest-priority task first

`recoverOrphanedAssignments` was selecting the first element from `getAgentTasks` without sorting, so the task recovered depended on storage insertion order rather than priority. A P3 task created before a P1 task would be dispatched first.

Adds an ascending sort by `task.priority` before selecting the task to recover, matching the intent of priority-based scheduling. Adds a regression test with a P3 task created before a P1 task to confirm the P1 task is always recovered first.
58 changes: 58 additions & 0 deletions packages/smithy/src/services/dispatch-daemon.bun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,64 @@ describe('recoverOrphanedAssignments', () => {

expect(result.processed).toBe(0);
});

test('recovers highest-priority task first when multiple are assigned', async () => {
const { createTask: createTaskFn } = await import('@stoneforge/core');

const worker = await createTestWorker('priority-grace');
const workerId = worker.id as unknown as EntityId;

// Create P3 (low) task FIRST — naive creation-order selection would pick this
const lowPriorityTaskData = await createTaskFn({
title: 'Low priority task',
createdBy: systemEntity,
status: TaskStatus.OPEN,
priority: Priority.LOW,
assignee: workerId,
});
const savedLow = await api.create(lowPriorityTaskData as unknown as Record<string, unknown> & { createdBy: EntityId }) as Task;
await api.update(savedLow.id, {
metadata: updateOrchestratorTaskMeta(undefined, {
assignedAgent: workerId,
branch: 'agent/priority-grace/low-task-branch',
worktree: '/worktrees/priority-grace/low-task',
}),
});

// Create P1 (critical) task SECOND
const highPriorityTaskData = await createTaskFn({
title: 'High priority task',
createdBy: systemEntity,
status: TaskStatus.OPEN,
priority: Priority.CRITICAL,
assignee: workerId,
});
const savedHigh = await api.create(highPriorityTaskData as unknown as Record<string, unknown> & { createdBy: EntityId }) as Task;
await api.update(savedHigh.id, {
metadata: updateOrchestratorTaskMeta(undefined, {
assignedAgent: workerId,
branch: 'agent/priority-grace/high-task-branch',
worktree: '/worktrees/priority-grace/high-task',
}),
});

const result = await daemon.recoverOrphanedAssignments();

expect(result.processed).toBe(1);
// Must have spawned a session in the HIGH priority task's worktree, not the low one
expect(sessionManager.startSession).toHaveBeenCalledWith(
workerId,
expect.objectContaining({
workingDirectory: '/worktrees/priority-grace/high-task',
})
);
expect(sessionManager.startSession).not.toHaveBeenCalledWith(
workerId,
expect.objectContaining({
workingDirectory: '/worktrees/priority-grace/low-task',
})
);
});
});

describe('pollWorkflowTasks - merge steward dispatch', () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/smithy/src/services/dispatch-daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1248,7 +1248,12 @@ export class DispatchDaemonImpl implements DispatchDaemon {
}

// 4. Check if the task is stuck in a resume loop
const taskAssignment = workerTasks[0];
// Sort by priority ascending (lower number = higher priority) so the
// highest-priority assigned task is always recovered first.
const sortedWorkerTasks = [...workerTasks].sort(
(a, b) => (a.task.priority ?? 99) - (b.task.priority ?? 99)
);
const taskAssignment = sortedWorkerTasks[0];
let resumeCount = taskAssignment.orchestratorMeta?.resumeCount ?? 0;
const maxResumes = this.config.maxResumeAttemptsBeforeRecovery;

Expand Down