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
7 changes: 7 additions & 0 deletions server/services/cosTaskGenerator.js
Original file line number Diff line number Diff line change
Expand Up @@ -1837,6 +1837,13 @@ export async function generateSelfImprovementTaskForType(taskType, state) {
const taskSchedule = await import('./taskSchedule.js');
const { getTaskPrompt } = await import('./taskPromptService.js');
const interval = await taskSchedule.getTaskInterval(taskType);
// App-scoped task types must never fall through this global lane. The
// on-demand request gate normally rejects a missing appId, but scheduled
// rotation and older callers can still reach the generator directly.
if (taskSchedule.requiresManagedAppTarget(taskType)) {
emitLog('warn', `Skipping ${taskType} without a managed app target`);
return null;
}
let description = await getTaskPrompt(taskType);

const metadata = {
Expand Down
10 changes: 9 additions & 1 deletion server/services/cosTaskGenerator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ describe('isConfiguredApprovalRequired', () => {
it('both generators stamp approvalReason onto metadata so the hint survives COS-TASKS.md', () => {
const selfStart = GEN_SRC.indexOf('export async function generateSelfImprovementTaskForType');
const appStart = GEN_SRC.indexOf('export async function generateManagedAppImprovementTaskForType');
expect(GEN_SRC.slice(selfStart, selfStart + 4500)).toContain('stampApprovalReason(metadata, approval)');
expect(GEN_SRC.slice(selfStart, appStart)).toContain('stampApprovalReason(metadata, approval)');
expect(GEN_SRC.slice(appStart, appStart + 12000)).toContain('stampApprovalReason(metadata, approval)');
});

Expand Down Expand Up @@ -1746,6 +1746,14 @@ describe('the drain cap has exactly one implementation, at the choke point', ()
});

describe('pr-reviewer security preflight wiring', () => {
it('does not allow the global generator to bypass the managed-app target boundary', () => {
const start = GEN_SRC.indexOf('export async function generateSelfImprovementTaskForType');
const body = GEN_SRC.slice(start, start + 1800);
expect(body).toContain('taskSchedule.requiresManagedAppTarget(taskType)');
expect(body).toContain('Skipping ${taskType} without a managed app target');
expect(body).toContain('return null;');
});

it('runs the direct preflight before stage gates and resolves the next-stage prompt', () => {
const start = GEN_SRC.indexOf('export async function generateManagedAppImprovementTaskForType');
const body = GEN_SRC.slice(start, GEN_SRC.indexOf('return task;', start));
Expand Down
9 changes: 8 additions & 1 deletion server/services/taskSchedule.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ import {
import {
DEFAULT_TASK_INTERVALS,
INSTALL_WIDE_TASK_TYPES,
MANAGED_APP_TARGET_TASK_TYPES,
MANAGED_AGENT_OPTIONS,
getTaskTypeDescription,
getTaskTypeInvocation,
getTaskTypePromptInfo,
requiresManagedAppTarget,
enforceBranchReconcileBatch,
enforceManagedAgentOptions
} from './taskScheduleRegistry.js';
Expand All @@ -64,9 +66,11 @@ export {
} from './taskScheduleConstants.js';
export {
DEFAULT_BRANCHES_PER_AGENT, DEFAULT_TASK_INTERVALS, INSTALL_WIDE_TASK_TYPES,
MANAGED_APP_TARGET_TASK_TYPES,
MANAGED_AGENT_OPTIONS, PERPETUAL_DRAIN_DISPATCH_CAP, SELF_IMPROVEMENT_TASK_TYPES,
TASK_TYPE_DESCRIPTIONS, TASK_TYPE_INVOCATION, TASK_TYPE_PROMPT_INFO,
getTaskTypeInvocation, getTaskTypePromptInfo, stripManagedAgentOptionsFromOverride
getTaskTypeInvocation, getTaskTypePromptInfo, requiresManagedAppTarget,
stripManagedAgentOptionsFromOverride
} from './taskScheduleRegistry.js';
export { loadSchedule } from './taskScheduleStore.js';
export {
Expand Down Expand Up @@ -1149,6 +1153,9 @@ export async function triggerOnDemandTask(taskType, appId = null, { emit = true,
if (origin === ON_DEMAND_ORIGINS.USER && !invocation.userInvokable) {
return { result: { error: `Task type '${taskType}' is managed by another automation and cannot be run manually` }, changed: false };
}
if (requiresManagedAppTarget(taskType) && !appId) {
return { result: { error: `Task type '${taskType}' requires a managed app target` }, changed: false };
}

// Reject if the master Improve toggle is off — request would be silently dropped downstream
const state = await loadState();
Expand Down
29 changes: 29 additions & 0 deletions server/services/taskSchedule.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,14 @@ import {
FAILURE_PARK_THRESHOLD,
PROMPT_VERSIONS,
DEFAULT_TASK_INTERVALS,
MANAGED_APP_TARGET_TASK_TYPES,
MANAGED_AGENT_OPTIONS,
stripManagedAgentOptionsFromOverride,
TASK_TYPE_DESCRIPTIONS,
TASK_TYPE_INVOCATION,
TASK_TYPE_PROMPT_INFO,
getTaskTypeInvocation,
requiresManagedAppTarget,
REFERENCE_WATCH_AUDITED_VERSION,
boundParkedUntil
} from './taskSchedule.js'
Expand Down Expand Up @@ -284,6 +286,21 @@ describe('taskSchedule', () => {
})
})

describe('managed-app target task types', () => {
it('keeps app-required scope explicit and separate from install-wide scope', () => {
expect([...MANAGED_APP_TARGET_TASK_TYPES]).toEqual(['pr-reviewer'])
expect(requiresManagedAppTarget('pr-reviewer')).toBe(true)
expect(requiresManagedAppTarget('security')).toBe(false)
expect(requiresManagedAppTarget('repo-sync')).toBe(false)
})

it('only names registered task types', () => {
for (const taskType of MANAGED_APP_TARGET_TASK_TYPES) {
expect(SELF_IMPROVEMENT_TASK_TYPES).toContain(taskType)
}
})
})

describe('TASK_TYPE_DESCRIPTIONS', () => {
// Guards against the "orphaned task" bug: a task type with no description
// entry falls back to a dasherized label ("claim work") in the schedule UI,
Expand Down Expand Up @@ -1930,6 +1947,18 @@ describe('taskSchedule', () => {
expect(result.origin).toBe(ON_DEMAND_ORIGINS.USER)
})

it('rejects an app-required task without a managed app target', async () => {
mockSchedule({
tasks: { 'pr-reviewer': { type: INTERVAL_TYPES.ON_DEMAND, enabled: true } }
})

const result = await triggerOnDemandTask('pr-reviewer')

expect(result.error).toMatch(/requires a managed app target/i)
expect(recordUserAction).not.toHaveBeenCalled()
expect((await getOnDemandRequests()).filter(r => r.taskType === 'pr-reviewer')).toHaveLength(0)
})

it('should reject unknown task types instead of silently queuing them', async () => {
mockSchedule({
tasks: { 'feature-ideas': { type: 'weekly', enabled: true } }
Expand Down
3 changes: 2 additions & 1 deletion server/services/taskScheduleModules.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ describe('taskSchedule module boundaries', () => {
...['INTERVAL_TYPES', 'ON_DEMAND_ORIGINS', 'isRefillRequest']
.map((name) => [name, constants[name]]),
...['DEFAULT_BRANCHES_PER_AGENT', 'DEFAULT_TASK_INTERVALS', 'MANAGED_AGENT_OPTIONS',
'MANAGED_APP_TARGET_TASK_TYPES',
'PERPETUAL_DRAIN_DISPATCH_CAP', 'SELF_IMPROVEMENT_TASK_TYPES', 'TASK_TYPE_DESCRIPTIONS',
'TASK_TYPE_INVOCATION', 'TASK_TYPE_PROMPT_INFO', 'getTaskTypeInvocation', 'getTaskTypePromptInfo',
'stripManagedAgentOptionsFromOverride'].map((name) => [name, registry[name]]),
'requiresManagedAppTarget', 'stripManagedAgentOptionsFromOverride'].map((name) => [name, registry[name]]),
...['FAILURE_BACKOFF_BASE_MS', 'FAILURE_BACKOFF_CAP_MS', 'FAILURE_PARK_THRESHOLD',
'clearTaskTypeFailurePark', 'computeFailureBackoffMs', 'recordTaskTypeFailure',
'recordTaskTypeSuccess'].map((name) => [name, backoff[name]]),
Expand Down
10 changes: 10 additions & 0 deletions server/services/taskScheduleRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,16 @@ export const DEFAULT_BRANCHES_PER_AGENT = 3;
*/
export const INSTALL_WIDE_TASK_TYPES = new Set(['repo-sync', 'user-action-review']);

// Task types that only make sense when pointed at a managed app. Keeping this
// alongside the install-wide registry gives both the on-demand request gate
// and the global generator one target-scope contract; neither has to infer
// scope from a task name or from which generator happened to receive a call.
export const MANAGED_APP_TARGET_TASK_TYPES = new Set(['pr-reviewer']);

export function requiresManagedAppTarget(taskType) {
return MANAGED_APP_TARGET_TASK_TYPES.has(taskType);
}

// Fresh installs expose every task as an enabled manual action. The on-demand
// type keeps provider work silent until the user explicitly runs a task, while
// retaining timing metadata such as custom intervals and recheck settings if
Expand Down