Skip to content
Merged
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
96 changes: 54 additions & 42 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions src/agents/prompts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ export interface PromptContext {

// PM list/column IDs
backlogListId?: string;
/**
* Value to pass as `expectedSourceState` to the MoveWorkItem gadget
* when moving an item out of BACKLOG. Per-provider:
* - Trello: backlog list ID (matches `WorkItem.status: card.idList`)
* - JIRA: "Backlog" status name
* - Linear: "Backlog" workflow-state name
*/
backlogSourceLabel?: string;
todoListId?: string;
inProgressListId?: string;
inReviewListId?: string;
Expand Down Expand Up @@ -313,6 +321,12 @@ export function getTemplateVariables(): Array<{
{ name: 'workItemNounPluralCap', group: 'PM', description: 'Cards or Issues' },
{ name: 'pmName', group: 'PM', description: 'Trello or JIRA' },
{ name: 'backlogListId', group: 'PM Lists', description: 'Backlog list/column ID' },
{
name: 'backlogSourceLabel',
group: 'PM Lists',
description:
'Value to pass as MoveWorkItem `expectedSourceState` for items in BACKLOG (provider-correct: Trello list ID, JIRA/Linear status name).',
},
{ name: 'todoListId', group: 'PM Lists', description: 'TODO list/column ID' },
{ name: 'inProgressListId', group: 'PM Lists', description: 'In Progress list/column ID' },
{ name: 'inReviewListId', group: 'PM Lists', description: 'In Review list/column ID' },
Expand Down
2 changes: 1 addition & 1 deletion src/agents/prompts/templates/backlog-manager.eta
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ When the active pipeline has capacity:
- <%= it.workItemNounPluralCap || 'Cards' %> that don't reference incomplete work
<% if ((it.maxInFlightItems ?? 1) > 1) { %> - **Conflict Awareness**: When selecting multiple <%= it.workItemNounPlural || 'cards' %>, review in-flight work descriptions to minimize file-level conflicts between simultaneously active <%= it.workItemNounPlural || 'cards' %>. Prefer <%= it.workItemNounPlural || 'cards' %> that touch different areas of the codebase.
<% } %>5. **Post a comment** on each selected <%= it.workItemNoun || 'card' %> explaining the selection
6. **Move the selected <%= it.workItemNoun || 'card' %>(s)** using `MoveWorkItem` with the TODO list ID as destination
6. **Move the selected <%= it.workItemNoun || 'card' %>(s)** using `MoveWorkItem` with the TODO list ID as destination AND `expectedSourceState: <%= it.backlogSourceLabel || 'Backlog' %>`. The `expectedSourceState` guard is **mandatory** — it aborts the move if a parallel run already moved the <%= it.workItemNoun || 'card' %> out of BACKLOG, preventing duplicate downstream implementation runs.

## Comment Format

Expand Down
10 changes: 10 additions & 0 deletions src/agents/shared/promptContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ function getListIds(project: ProjectConfig) {
const linearConfig = getLinearConfig(project);

return {
// Value the agent should pass as `expectedSourceState` when moving an
// item out of BACKLOG. Aligned with what `WorkItem.status` returns
// per provider:
// - Trello: `WorkItem.status` is the list ID → pass list ID.
// - JIRA: `WorkItem.status` is the status name → pass status name.
// - Linear: `WorkItem.status` is the workflow-state name → pass the
// state name. Linear teams default to literal "Backlog";
// customized teams can still pass it via the prompt path
// since the gadget guard is case-insensitive.
backlogSourceLabel: trelloConfig?.lists?.backlog ?? jiraConfig?.statuses?.backlog ?? 'Backlog',
backlogListId:
trelloConfig?.lists?.backlog ?? jiraConfig?.statuses?.backlog ?? linearConfig?.teamId,
todoListId:
Expand Down
1 change: 1 addition & 0 deletions src/cli/pm/move-work-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export default createCLICommand(moveWorkItemDef, async (params) => {
return moveWorkItem({
workItemId: params.workItemId as string,
destination: params.destination as string,
expectedSourceState: params.expectedSourceState as string | undefined,
});
});
34 changes: 34 additions & 0 deletions src/gadgets/pm/core/moveWorkItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,44 @@ import { getPMProvider } from '../../../pm/index.js';
export interface MoveWorkItemParams {
workItemId: string;
destination: string;
/**
* Optional pre-move guard. When provided, the gadget fetches the work
* item's current status and refuses to move unless it matches (case-
* insensitive). Defends against parallel-agent races — e.g. a second
* backlog-manager run trying to move an item that's already been
* moved out of BACKLOG by a sibling run (incident 2026-05-06, MNG-538).
*
* If the current status already equals `destination`, the move is
* skipped as a no-op (idempotent).
*/
expectedSourceState?: string;
}

function normalizeStatus(s: string | undefined): string {
return (s ?? '').trim().toLowerCase();
}

export async function moveWorkItem(params: MoveWorkItemParams): Promise<string> {
try {
if (params.expectedSourceState !== undefined) {
const provider = getPMProvider();
const current = await provider.getWorkItem(params.workItemId);
const currentStatus = normalizeStatus(current.status);
const expected = normalizeStatus(params.expectedSourceState);
const destination = normalizeStatus(params.destination);

if (currentStatus && currentStatus === destination) {
return `Work item ${params.workItemId} already in destination state '${current.status}' — no-op`;
}

if (currentStatus !== expected) {
return `Aborted: work item ${params.workItemId} is in '${current.status ?? 'unknown'}', expected '${params.expectedSourceState}' (likely already moved by a parallel agent — skipping to avoid duplicate downstream work)`;
}

await provider.moveWorkItem(params.workItemId, params.destination);
return `Work item ${params.workItemId} moved to ${params.destination} successfully`;
}

await getPMProvider().moveWorkItem(params.workItemId, params.destination);
return `Work item ${params.workItemId} moved to ${params.destination} successfully`;
} catch (error) {
Expand Down
15 changes: 15 additions & 0 deletions src/gadgets/pm/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ export const moveWorkItemDef: ToolDefinition = {
describe: 'Destination — Trello list ID or JIRA status name',
required: true,
},
expectedSourceState: {
type: 'string',
describe:
'Optional pre-move guard. If set, the move only proceeds when the work item\'s current status matches this value (case-insensitive). Use this whenever the move depends on a prior pipeline-state assumption (e.g. "BACKLOG" before moving to TODO) to defend against parallel-agent races. If the work item is already in the destination state, the move is skipped as a no-op.',
required: false,
},
},
examples: [
{
Expand All @@ -210,6 +216,15 @@ export const moveWorkItemDef: ToolDefinition = {
},
comment: 'Move a Trello card to a different list',
},
{
params: {
workItemId: 'MNG-538',
destination: 'TODO_LIST_ID',
expectedSourceState: 'Backlog',
},
comment:
'Backlog-manager moving a freshly-picked item to TODO — guarded so a parallel run that already moved it cannot duplicate the move.',
},
],
};

Expand Down
38 changes: 34 additions & 4 deletions src/router/work-item-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,39 @@ export const MAX_SAME_TYPE_PER_WORK_ITEM = 1;

const TTL_MS = 30 * 60 * 1000; // 30 minutes

/**
* Agent types that act on the project's whole backlog rather than a single
* work item. Two parallel runs MUST serialize at the project level, even
* when their nominal `workItemId` differs (e.g. backlog-manager auto-chained
* from MNG-536's PR merge AND from MNG-537's splitting completion both scan
* the same backlog and can pick the same item — live incident 2026-05-06,
* MNG-538 produced PRs #287 and #288).
*
* For these agents the lock key collapses workItemId to a sentinel, and
* the DB count omits workItemId so all rows for the agent type in the
* project are counted together.
*/
const PROJECT_SINGLETON_AGENTS = new Set<string>(['backlog-manager']);

const SINGLETON_WORK_ITEM_KEY = '*';

function isProjectSingletonAgent(agentType: string): boolean {
return PROJECT_SINGLETON_AGENTS.has(agentType);
}

interface EnqueuedEntry {
timestamp: number;
count: number;
}

const enqueuedMap = new Map<string, EnqueuedEntry>();

function effectiveWorkItemId(workItemId: string, agentType: string): string {
return isProjectSingletonAgent(agentType) ? SINGLETON_WORK_ITEM_KEY : workItemId;
}

function makeKey(projectId: string, workItemId: string, agentType: string): string {
return `${projectId}:${workItemId}:${agentType}`;
return `${projectId}:${effectiveWorkItemId(workItemId, agentType)}:${agentType}`;
}

/**
Expand Down Expand Up @@ -78,15 +102,21 @@ export async function isWorkItemLocked(
};
}

// DB check — same-type only, ignore runs older than 2× worker timeout
// DB check — same-type only, ignore runs older than 2× worker timeout.
// For project-singleton agents, omit workItemId so all rows for the
// agent type within the project count toward the limit.
const maxAgeMs = 2 * routerConfig.workerTimeoutMs;
const dbSameType = await countActiveRuns({ projectId, workItemId, agentType, maxAgeMs });
const dbQuery = isProjectSingletonAgent(agentType)
? { projectId, agentType, maxAgeMs }
: { projectId, workItemId, agentType, maxAgeMs };
const dbSameType = await countActiveRuns(dbQuery);

const effectiveSameType = Math.max(dbSameType, inMemorySameType);
if (effectiveSameType >= MAX_SAME_TYPE_PER_WORK_ITEM) {
const scope = isProjectSingletonAgent(agentType) ? 'project-singleton' : 'same-type';
return {
locked: true,
reason: `same-type: ${dbSameType} running, ${inMemorySameType} enqueued (max ${MAX_SAME_TYPE_PER_WORK_ITEM} per type)`,
reason: `${scope}: ${dbSameType} running, ${inMemorySameType} enqueued (max ${MAX_SAME_TYPE_PER_WORK_ITEM} per type)`,
};
}

Expand Down
Loading
Loading