diff --git a/src/agents/definitions/backlog-manager.yaml b/src/agents/definitions/backlog-manager.yaml index 3664b010..e8646c5f 100644 --- a/src/agents/definitions/backlog-manager.yaml +++ b/src/agents/definitions/backlog-manager.yaml @@ -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. diff --git a/src/agents/prompts/templates/backlog-manager.eta b/src/agents/prompts/templates/backlog-manager.eta index d31c80e8..70ef4baa 100644 --- a/src/agents/prompts/templates/backlog-manager.eta +++ b/src/agents/prompts/templates/backlog-manager.eta @@ -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. @@ -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. @@ -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 @@ -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" diff --git a/src/triggers/github/pr-merged.ts b/src/triggers/github/pr-merged.ts index 0f90b90a..0d145051 100644 --- a/src/triggers/github/pr-merged.ts +++ b/src/triggers/github/pr-merged.ts @@ -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 }); diff --git a/src/triggers/shared/agent-execution.ts b/src/triggers/shared/agent-execution.ts index c72796d1..c3ffe7db 100644 --- a/src/triggers/shared/agent-execution.ts +++ b/src/triggers/shared/agent-execution.ts @@ -734,6 +734,7 @@ async function propagateAutoLabelAfterSplitting( reason: capacityResult.reason, inFlightCount: capacityResult.inFlightCount, limit: capacityResult.limit, + availableSlots: capacityResult.availableSlots, }, ); return null; diff --git a/src/triggers/shared/backlog-check.ts b/src/triggers/shared/backlog-check.ts index 074c3c7b..cb20459f 100644 --- a/src/triggers/shared/backlog-check.ts +++ b/src/triggers/shared/backlog-check.ts @@ -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; } /** @@ -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( @@ -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, diff --git a/tests/unit/agents/prompts.test.ts b/tests/unit/agents/prompts.test.ts index 34f58fc6..76f851ae 100644 --- a/tests/unit/agents/prompts.test.ts +++ b/tests/unit/agents/prompts.test.ts @@ -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', () => { diff --git a/tests/unit/triggers/shared/backlog-check.test.ts b/tests/unit/triggers/shared/backlog-check.test.ts index 95943ac4..cd119647 100644 --- a/tests/unit/triggers/shared/backlog-check.test.ts +++ b/tests/unit/triggers/shared/backlog-check.test.ts @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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) }), @@ -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 () => { @@ -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 () => { @@ -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 }); }); @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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) }), @@ -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 () => { @@ -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 () => { @@ -580,6 +597,7 @@ describe('isPipelineAtCapacity', () => { expect(result.atCapacity).toBe(false); expect(result.reason).toBe('misconfigured'); + expect(result.availableSlots).toBeUndefined(); }); }); @@ -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' }); }); @@ -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 () => { @@ -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 () => { @@ -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(); }); @@ -678,6 +700,7 @@ describe('isPipelineAtCapacity', () => { expect(result.atCapacity).toBe(false); expect(result.reason).toBe('misconfigured'); + expect(result.availableSlots).toBeUndefined(); }); }); diff --git a/tests/unit/worker-entry.test.ts b/tests/unit/worker-entry.test.ts index 74b0853b..4b3686cd 100644 --- a/tests/unit/worker-entry.test.ts +++ b/tests/unit/worker-entry.test.ts @@ -516,6 +516,12 @@ describe('main() - environment variable validation', () => { let exitSpy: ReturnType; 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})`); });