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
4 changes: 2 additions & 2 deletions src/agents/definitions/backlog-manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ prompts:
A Pipeline Snapshot has been pre-loaded into your context with the current state of all pipeline lists (BACKLOG, TODO, IN_PROGRESS, IN_REVIEW, DONE, MERGED).

1. Review the pre-loaded Pipeline Snapshot and count items currently in the active pipeline (TODO + IN PROGRESS + IN REVIEW).
2. If the count is below the capacity limit (see system prompt): use the pre-loaded BACKLOG data from the snapshot to select the best unblocked item(s) and move them to TODO (up to the remaining capacity).
2. If the count is below the capacity limit (see system prompt): use the pre-loaded BACKLOG data from the snapshot to select ALL eligible unblocked items to fill remaining capacity completely — always move the maximum number possible.
3. If already at or above capacity: exit immediately without taking action.

hint: Only act if pipeline has capacity (items in TODO + IN PROGRESS + IN REVIEW < maxInFlightItems).
hint: Only act if pipeline has capacity (items in TODO + IN PROGRESS + IN REVIEW < maxInFlightItems). When acting, ALWAYS fill ALL remaining capacity.
10 changes: 6 additions & 4 deletions src/agents/prompts/templates/backlog-manager.eta
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Use these EXACT IDs when calling `ListWorkItems` and `MoveWorkItem`:

CRITICAL:
1. **CHECK PIPELINE FIRST** - Count items in the active pipeline (TODO + IN PROGRESS + IN REVIEW) and compare to the capacity limit (<%= it.maxInFlightItems ?? 1 %>).
2. **CAPACITY LIMIT** - <%= it.maxInFlightItems == null || it.maxInFlightItems === 1 ? 'Move exactly one ' + (it.workItemNoun || 'card') + ' per run. Never move multiple.' : 'Move up to ' + it.maxInFlightItems + ' ' + (it.workItemNounPlural || 'cards') + ' per run (only enough to fill remaining capacity).' %>
2. **CAPACITY LIMIT** - <%= it.maxInFlightItems == null || it.maxInFlightItems === 1 ? 'Move exactly one ' + (it.workItemNoun || 'card') + ' per run. Never move multiple.' : 'You MUST fill ALL remaining capacity. Move up to ' + it.maxInFlightItems + ' ' + (it.workItemNounPlural || 'cards') + ' per run — always move as many eligible items as there are open slots.' %>
3. **READ BEFORE SELECTING** - Read <%= it.workItemNoun || 'card' %> contents, descriptions, and checklists to make an informed decision.
4. DO NOT MANAGE LABELS - Labels are handled automatically by the system.

Expand All @@ -34,7 +34,7 @@ A **Pipeline Snapshot** has been pre-loaded into your context containing the cur
- Do NOT post any comments, do NOT scan the backlog
- Simply end the session

3. **Only if the active pipeline count is below the capacity limit**, proceed to backlog selection. The number of <%= it.workItemNounPlural || 'cards' %> you may move = capacity limit (<%= it.maxInFlightItems ?? 1 %>) minus current active count.
3. **Only if the active pipeline count is below the capacity limit**, proceed to backlog selection. You MUST move exactly min(remaining_capacity, eligible_unblocked_items) <%= it.workItemNounPlural || 'cards' %>. Remaining capacity = capacity limit (<%= it.maxInFlightItems ?? 1 %>) minus current active count. If 2 open slots and 2 eligible items exist, move BOTH.

Note: DONE and MERGED <%= it.workItemNounPlural || 'cards' %> are completed work and do not block new work from being selected. The snapshot shows their titles and URLs for dependency checking.

Expand All @@ -53,7 +53,7 @@ When the active pipeline has capacity:
- Comments indicating external dependencies
- **Stale annotations**: Text like "(not yet merged)" in a description was written when the <%= it.workItemNoun || 'card' %> was created and is **always stale**. Do NOT use it as evidence of blocked status — only the MERGED list itself is authoritative.
- **IMPORTANT — MERGED check**: Before declaring a <%= it.workItemNoun || 'card' %> blocked, scan the MERGED section of the Pipeline Snapshot. Use **substring matching**: if the dependency name (e.g., "SCMIntegration", "OpenCodeEngine", "integrationRoles") appears anywhere within a MERGED title, that dependency is **resolved** and does NOT block. Each MERGED entry also shows its URL in parentheses — if the description references a <%= it.pmName || 'PM' %> link, match it against the URL too. A module or class name found anywhere in a title counts as a match.
4. **Select the best unblocked <%= it.workItemNoun || 'card' %>(s)** considering:
4. **Select ALL eligible unblocked <%= it.workItemNounPlural || 'cards' %> up to remaining capacity** considering:
- Smaller, self-contained <%= it.workItemNounPlural || 'cards' %> are preferred
- <%= it.workItemNounPluralCap || 'Cards' %> with clear acceptance criteria
- <%= it.workItemNounPluralCap || 'Cards' %> that don't reference incomplete work
Expand Down Expand Up @@ -100,7 +100,9 @@ Manual intervention may be needed to unblock the backlog.
- NEVER move <%= it.workItemNounPlural || 'cards' %> if the active pipeline is at capacity (<%= it.maxInFlightItems ?? 1 %> item(s))
- EXIT SILENTLY if pipeline is at capacity - do not post comments
- ALWAYS read <%= it.workItemNoun || 'card' %> contents before making a selection decision
- <%= it.maxInFlightItems == null || it.maxInFlightItems === 1 ? 'ALWAYS move exactly ONE ' + (it.workItemNoun || 'card') + ' per run' : 'Move only as many ' + (it.workItemNounPlural || 'cards') + ' as needed to reach capacity (limit: ' + it.maxInFlightItems + ')' %>
- <%= it.maxInFlightItems == null || it.maxInFlightItems === 1 ? 'ALWAYS move exactly ONE ' + (it.workItemNoun || 'card') + ' per run' : 'ALWAYS maximize throughput — fill ALL capacity slots with eligible items (limit: ' + it.maxInFlightItems + '). Never move fewer when eligible items exist.' %>
<% if ((it.maxInFlightItems ?? 1) > 1) { %>- MAXIMIZE THROUGHPUT — if remaining capacity is <%= it.maxInFlightItems %> and <%= it.maxInFlightItems %>+ unblocked items exist, you MUST move <%= it.maxInFlightItems %> items, not fewer.
<% } %>
- ALWAYS post a comment BEFORE moving the <%= it.workItemNoun || 'card' %> — comment first, then move to TODO
- CONSERVATIVE on detecting dependencies — when unsure if text implies a dependency, treat it as one. But GENEROUS on MERGED resolution — use substring matching and prefer resolved over blocked for ambiguous matches.
- LOOK FOR dependency keywords: "blocked by", "depends on", "waiting for", "after", "requires"
Expand Down
1 change: 1 addition & 0 deletions src/triggers/github/pr-merged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export class PRMergedTrigger implements TriggerHandler {
reason: capacityResult.reason,
inFlightCount: capacityResult.inFlightCount,
limit: capacityResult.limit,
availableSlots: capacityResult.availableSlots,
});
} else {
logger.info('Chaining to backlog-manager after PR merge', { workItemId, prNumber });
Expand Down
1 change: 1 addition & 0 deletions src/triggers/shared/agent-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,7 @@ async function propagateAutoLabelAfterSplitting(
reason: capacityResult.reason,
inFlightCount: capacityResult.inFlightCount,
limit: capacityResult.limit,
availableSlots: capacityResult.availableSlots,
},
);
return null;
Expand Down
27 changes: 23 additions & 4 deletions src/triggers/shared/backlog-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export interface PipelineCapacityResult {
inFlightCount?: number;
/** The effective capacity limit used for the comparison. */
limit?: number;
/**
* Number of open slots available (limit - inFlightCount).
* - `'backlog-empty'`: equals limit (pipeline has capacity but nothing to fill it)
* - `'at-capacity'`: 0 (no open slots)
* - `'below-capacity'`: limit - inFlightCount (slots waiting to be filled)
* - `'error'` / `'misconfigured'`: undefined (cannot be computed)
*/
availableSlots?: number;
}

/**
Expand Down Expand Up @@ -113,8 +121,17 @@ export async function isPipelineAtCapacity(
// key, mapped to the provider's native identifier internally.
const backlogItems = await provider.listWorkItems(undefined, { status: 'backlog' });
if (backlogItems.length === 0) {
logger.info('isPipelineAtCapacity: backlog is empty', { projectId: project.id });
return { atCapacity: true, reason: 'backlog-empty', inFlightCount: 0, limit };
logger.info('isPipelineAtCapacity: backlog is empty', {
projectId: project.id,
availableSlots: limit,
});
return {
atCapacity: true,
reason: 'backlog-empty',
inFlightCount: 0,
limit,
availableSlots: limit,
};
}

const inFlightLists = await Promise.all(
Expand All @@ -129,11 +146,13 @@ export async function isPipelineAtCapacity(
projectId: project.id,
inFlightCount,
limit,
availableSlots: 0,
});
return { atCapacity: true, reason: 'at-capacity', inFlightCount, limit };
return { atCapacity: true, reason: 'at-capacity', inFlightCount, limit, availableSlots: 0 };
}

return { atCapacity: false, reason: 'below-capacity', inFlightCount, limit };
const availableSlots = limit - inFlightCount;
return { atCapacity: false, reason: 'below-capacity', inFlightCount, limit, availableSlots };
} catch (err) {
logger.warn('isPipelineAtCapacity: failed to check capacity, assuming not at capacity', {
projectId: project.id,
Expand Down
21 changes: 19 additions & 2 deletions tests/unit/agents/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,26 @@ describe('system prompts content', () => {
it('backlog-manager prompt renders multi-item wording when limit>1', () => {
const prompt = getSystemPrompt('backlog-manager', { maxInFlightItems: 3 });
expect(prompt).toContain(
'Move up to 3 cards per run (only enough to fill remaining capacity).',
'You MUST fill ALL remaining capacity. Move up to 3 cards per run — always move as many eligible items as there are open slots.',
);
expect(prompt).toContain('Move only as many cards as needed to reach capacity (limit: 3)');
expect(prompt).toContain(
'ALWAYS maximize throughput — fill ALL capacity slots with eligible items (limit: 3). Never move fewer when eligible items exist.',
);
});

it('backlog-manager prompt includes maximize-throughput rule when limit>1', () => {
const prompt = getSystemPrompt('backlog-manager', { maxInFlightItems: 2 });
expect(prompt).toContain('MAXIMIZE THROUGHPUT');
expect(prompt).toContain('you MUST move 2 items, not fewer');
// Should NOT render the maximize-throughput rule for single-item mode
const promptSingle = getSystemPrompt('backlog-manager', { maxInFlightItems: 1 });
expect(promptSingle).not.toContain('MAXIMIZE THROUGHPUT');
});

it('backlog-manager prompt instructs exact count for multi-slot scenarios', () => {
const prompt = getSystemPrompt('backlog-manager', { maxInFlightItems: 2 });
expect(prompt).toContain('min(remaining_capacity, eligible_unblocked_items)');
expect(prompt).toContain('If 2 open slots and 2 eligible items exist, move BOTH');
});

it('backlog-manager prompt includes conflict awareness section when limit>1', () => {
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/triggers/shared/backlog-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('backlog-empty');
expect(result.inFlightCount).toBe(0);
expect(result.limit).toBe(1);
expect(result.availableSlots).toBe(1);
});

it('returns at-capacity when in-flight count equals limit (default 1)', async () => {
Expand All @@ -129,6 +130,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('at-capacity');
expect(result.inFlightCount).toBe(1);
expect(result.limit).toBe(1);
expect(result.availableSlots).toBe(0);
});

it('returns at-capacity when in-flight count exceeds limit', async () => {
Expand Down Expand Up @@ -166,6 +168,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('at-capacity');
expect(result.inFlightCount).toBe(3);
expect(result.limit).toBe(2);
expect(result.availableSlots).toBe(0);
});

it('returns below-capacity when in-flight count is below limit=3', async () => {
Expand Down Expand Up @@ -203,6 +206,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('below-capacity');
expect(result.inFlightCount).toBe(2);
expect(result.limit).toBe(3);
expect(result.availableSlots).toBe(1);
});

it('uses default limit=1 when maxInFlightItems is not set', async () => {
Expand Down Expand Up @@ -257,6 +261,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('below-capacity');
expect(result.inFlightCount).toBe(0);
expect(result.limit).toBe(5);
expect(result.availableSlots).toBe(5);
});

it('returns not-at-capacity (error fallback) when Trello API throws', async () => {
Expand All @@ -269,6 +274,7 @@ describe('isPipelineAtCapacity', () => {

expect(result.atCapacity).toBe(false);
expect(result.reason).toBe('error');
expect(result.availableSlots).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(
'isPipelineAtCapacity: failed to check capacity, assuming not at capacity',
expect.objectContaining({ projectId: trelloProject.id, error: expect.any(String) }),
Expand All @@ -283,6 +289,7 @@ describe('isPipelineAtCapacity', () => {

expect(result.atCapacity).toBe(false);
expect(result.reason).toBe('misconfigured');
expect(result.availableSlots).toBeUndefined();
});

it('returns misconfigured when Trello config is missing entirely', async () => {
Expand All @@ -293,6 +300,7 @@ describe('isPipelineAtCapacity', () => {

expect(result.atCapacity).toBe(false);
expect(result.reason).toBe('misconfigured');
expect(result.availableSlots).toBeUndefined();
});

it('counts items across todo, inProgress, and inReview lists', async () => {
Expand Down Expand Up @@ -331,6 +339,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('below-capacity');
expect(result.inFlightCount).toBe(6); // 2 + 1 + 3
expect(result.limit).toBe(10);
expect(result.availableSlots).toBe(4); // 10 - 6
});
});

Expand Down Expand Up @@ -371,6 +380,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('backlog-empty');
expect(result.inFlightCount).toBe(0);
expect(result.limit).toBe(1);
expect(result.availableSlots).toBe(1);
});

it('returns at-capacity when in-flight count equals limit=1', async () => {
Expand All @@ -394,6 +404,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('at-capacity');
expect(result.inFlightCount).toBe(1);
expect(result.limit).toBe(1);
expect(result.availableSlots).toBe(0);
});

it('returns below-capacity when in-flight count is less than limit=3', async () => {
Expand Down Expand Up @@ -432,6 +443,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('below-capacity');
expect(result.inFlightCount).toBe(2);
expect(result.limit).toBe(3);
expect(result.availableSlots).toBe(1);
});

it('returns at-capacity when in-flight count exceeds limit=2', async () => {
Expand Down Expand Up @@ -471,6 +483,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('at-capacity');
expect(result.inFlightCount).toBe(3);
expect(result.limit).toBe(2);
expect(result.availableSlots).toBe(0);
});

it('uses default limit=1 when maxInFlightItems is not set', async () => {
Expand Down Expand Up @@ -527,6 +540,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('below-capacity');
expect(result.inFlightCount).toBe(0);
expect(result.limit).toBe(5);
expect(result.availableSlots).toBe(5);
});

it('returns not-at-capacity (error fallback) when JIRA API throws', async () => {
Expand All @@ -540,6 +554,7 @@ describe('isPipelineAtCapacity', () => {

expect(result.atCapacity).toBe(false);
expect(result.reason).toBe('error');
expect(result.availableSlots).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(
'isPipelineAtCapacity: failed to check capacity, assuming not at capacity',
expect.objectContaining({ projectId: jiraProject.id, error: expect.any(String) }),
Expand All @@ -557,6 +572,7 @@ describe('isPipelineAtCapacity', () => {

expect(result.atCapacity).toBe(false);
expect(result.reason).toBe('misconfigured');
expect(result.availableSlots).toBeUndefined();
});

it('returns misconfigured when JIRA config has no projectKey', async () => {
Expand All @@ -570,6 +586,7 @@ describe('isPipelineAtCapacity', () => {

expect(result.atCapacity).toBe(false);
expect(result.reason).toBe('misconfigured');
expect(result.availableSlots).toBeUndefined();
});

it('returns misconfigured when JIRA config is missing entirely', async () => {
Expand All @@ -580,6 +597,7 @@ describe('isPipelineAtCapacity', () => {

expect(result.atCapacity).toBe(false);
expect(result.reason).toBe('misconfigured');
expect(result.availableSlots).toBeUndefined();
});
});

Expand Down Expand Up @@ -613,6 +631,7 @@ describe('isPipelineAtCapacity', () => {

expect(result.atCapacity).toBe(true);
expect(result.reason).toBe('backlog-empty');
expect(result.availableSlots).toBe(1);
expect(provider.listWorkItems).toHaveBeenCalledWith(undefined, { status: 'backlog' });
});

Expand All @@ -634,6 +653,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.reason).toBe('below-capacity');
expect(result.inFlightCount).toBe(0);
expect(result.limit).toBe(1);
expect(result.availableSlots).toBe(1);
});

it('returns at-capacity when Linear in-flight count meets the limit', async () => {
Expand All @@ -651,6 +671,7 @@ describe('isPipelineAtCapacity', () => {
expect(result.atCapacity).toBe(true);
expect(result.reason).toBe('at-capacity');
expect(result.inFlightCount).toBe(1);
expect(result.availableSlots).toBe(0);
});

it('returns misconfigured when Linear has no statuses.backlog configured', async () => {
Expand All @@ -664,6 +685,7 @@ describe('isPipelineAtCapacity', () => {

expect(result.atCapacity).toBe(false);
expect(result.reason).toBe('misconfigured');
expect(result.availableSlots).toBeUndefined();
expect(provider.listWorkItems).not.toHaveBeenCalled();
});

Expand All @@ -678,6 +700,7 @@ describe('isPipelineAtCapacity', () => {

expect(result.atCapacity).toBe(false);
expect(result.reason).toBe('misconfigured');
expect(result.availableSlots).toBeUndefined();
});
});

Expand Down
6 changes: 6 additions & 0 deletions tests/unit/worker-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,12 @@ describe('main() - environment variable validation', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
// Clear JOB_* env vars before each test — they may be inherited from the outer
// process (e.g. when running inside a CASCADE worker container). Tests that need
// specific values set them explicitly inside the test body; afterEach cleans up.
delete process.env.JOB_ID;
delete process.env.JOB_TYPE;
delete process.env.JOB_DATA;
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?) => {
throw new Error(`process.exit(${code ?? 0})`);
});
Expand Down
Loading