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: 6 additions & 1 deletion server/services/cos.js
Original file line number Diff line number Diff line change
Expand Up @@ -1616,7 +1616,12 @@ async function refillPerpetualForCompletedAgent(agent) {
// regenerates an identical first-line per app) is rejected as a duplicate of
// the completing task and the drain stalls until the next scheduler tick.
const cosTaskData = await getCosTasks();
await queueEligibleImprovementTasks(state, cosTaskData, { ignoreTaskId: agent?.taskId, wakeAfterRecord: false });
await queueEligibleImprovementTasks(state, cosTaskData, {
ignoreTaskId: agent?.taskId,
wakeAfterRecord: false,
// Continue only this completed drain: its cron slot already initiated it.
perpetualContinuation: { taskType: agentScheduledType(agent), appId: agent?.metadata?.taskApp || null }
});
// NOTE: the caller (the agent:completed handler) runs dequeueNextTask AFTER
// this resolves, so the freshly-queued perpetual task is on the queue before
// slots are filled. Do not dequeue here — that would re-introduce the ordering
Expand Down
2 changes: 1 addition & 1 deletion server/services/cos.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1817,7 +1817,7 @@ describe('cos.js source — priority + capacity invariants', () => {
expect(
fnBody,
'queue path must constrain the pick to perpetual when on cooldown (perpetualOnly gated on cooldown)'
).toMatch(/getNextTaskType\([^)]*\{\s*perpetualOnly:\s*onCooldown\s*\}/);
).toMatch(/getNextTaskType\([^)]*\{\s*perpetualOnly:\s*onCooldown\s*[,}]/);
});

it('generateManagedAppImprovementTaskForType defers updateAppActivity until after gates', () => {
Expand Down
7 changes: 5 additions & 2 deletions server/services/cosTaskGenerator.js
Original file line number Diff line number Diff line change
Expand Up @@ -1514,7 +1514,7 @@ export async function queueDueInstallWideImprovementTasks({
* Called during every evaluation to ensure system tasks are queued even when user tasks exist
* Tasks are queued to COS-TASKS.md and will be picked up in Priority 2
*/
export async function queueEligibleImprovementTasks(state, cosTaskData, { ignoreTaskId = null, wakeAfterRecord = true } = {}) {
export async function queueEligibleImprovementTasks(state, cosTaskData, { ignoreTaskId = null, wakeAfterRecord = true, perpetualContinuation = null } = {}) {
const taskSchedule = await import('./taskSchedule.js');
const { getDueTasks, getNextTaskType, recordExecution } = taskSchedule;

Expand Down Expand Up @@ -1598,7 +1598,10 @@ export async function queueEligibleImprovementTasks(state, cosTaskData, { ignore
// alone). When NOT on cooldown, the normal full-priority pick runs.
const onCooldown = isAppActivityOnCooldown(appActivity, state.config.appReviewCooldownMs);

const nextTypeResult = await getNextTaskType(app.id, { perpetualOnly: onCooldown }).catch(() => null);
const nextTypeResult = await getNextTaskType(app.id, {
perpetualOnly: onCooldown,
continuingTaskType: perpetualContinuation?.appId === app.id ? perpetualContinuation.taskType : null
}).catch(() => null);
if (!nextTypeResult) continue;
const nextType = nextTypeResult.taskType;

Expand Down
18 changes: 12 additions & 6 deletions server/services/taskSchedule.js
Original file line number Diff line number Diff line change
Expand Up @@ -790,9 +790,11 @@ async function checkRunAfterDeps(schedule, taskType, appId = null, featureEnable
}

/**
* Check if a task type should run for a specific app (or globally)
* Check if a task type should run for a specific app (or globally).
* Successful completion may continue its perpetual drain past the initiating
* cron slot; all eligibility and park gates still apply.
*/
export async function shouldRunTask(taskType, appId = null, { featureEnabled = createFeatureGate() } = {}) {
export async function shouldRunTask(taskType, appId = null, { featureEnabled = createFeatureGate(), continuePerpetual = false } = {}) {
if (appId && requiresInstallWideTarget(taskType)) {
return { shouldRun: false, reason: 'requires-install-wide-target' };
}
Expand Down Expand Up @@ -906,6 +908,10 @@ export async function shouldRunTask(taskType, appId = null, { featureEnabled = c
if (isPerpetual) {
const parked = perpetualParkResult();
if (parked) { result = parked; break; }
if (continuePerpetual) {
result = { shouldRun: true, reason: 'perpetual-drain' };
break;
}
// Unparked: the cron evaluation below decides whether to INITIATE a
// drain. Once one is running, the completion-refill lane keeps it going
// back-to-back regardless of subsequent ticks.
Expand Down Expand Up @@ -1015,15 +1021,15 @@ export async function shouldRunTask(taskType, appId = null, { featureEnabled = c
/**
* Get all enabled task types that are due to run (optionally for a specific app)
*/
export async function getDueTasks(appId = null) {
export async function getDueTasks(appId = null, { continuingTaskType = null } = {}) {
const schedule = await loadSchedule();
const due = [];
const featureEnabled = createFeatureGate();

for (const [taskType, interval] of Object.entries(schedule.tasks)) {
if (!interval.enabled) continue;

const check = await shouldRunTask(taskType, appId, { featureEnabled });
const check = await shouldRunTask(taskType, appId, { featureEnabled, continuePerpetual: taskType === continuingTaskType });
if (check.shouldRun) {
due.push({ taskType, reason: check.reason, interval });
}
Expand All @@ -1035,8 +1041,8 @@ export async function getDueTasks(appId = null) {
/**
* Get the next task type to run (optionally for a specific app)
*/
export async function getNextTaskType(appId = null, { perpetualOnly = false } = {}) {
const dueTasks = await getDueTasks(appId);
export async function getNextTaskType(appId = null, { perpetualOnly = false, continuingTaskType = null } = {}) {
const dueTasks = await getDueTasks(appId, { continuingTaskType });

// `perpetualOnly` constrains the pick to a due perpetual (drain-until-done)
// task, skipping every other schedule type. Callers set this when the app is
Expand Down
31 changes: 31 additions & 0 deletions server/services/taskSchedule.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2368,6 +2368,37 @@ describe('taskSchedule', () => {
expect(await getNextTaskType()).toBeNull()
})

it('continues only the completed cron drain after its initiating slot is consumed', async () => {
cronNotDueYet()
mockSchedule({
tasks: {
...PAUSED_SHIPPED_DRAINS,
'claim-issue': { type: 'cron', cronExpression: '0 7 * * *', perpetual: true, enabled: true },
security: { type: 'cron', cronExpression: '0 7 * * *', enabled: true }
},
executions: { 'task:claim-issue': { lastRun: new Date().toISOString(), count: 1, perApp: {} } }
})
expect(await getNextTaskType()).toBeNull()
expect(await getNextTaskType(null, { continuingTaskType: 'security' })).toBeNull()
expect(await getNextTaskType(null, { continuingTaskType: 'claim-issue', perpetualOnly: true }))
.toEqual({ taskType: 'claim-issue', reason: 'perpetual-drain' })
})

it.each(['parkedUntil', 'failureParkedAt'])('keeps continuation behind %s', async (field) => {
cronNotDueYet()
mockSchedule({
tasks: {
...PAUSED_SHIPPED_DRAINS,
'claim-issue': { type: 'cron', cronExpression: '0 7 * * *', perpetual: true, enabled: true }
},
executions: { 'task:claim-issue': {
lastRun: new Date().toISOString(), count: 1, perApp: {},
[field]: new Date(Date.now() + 3600000).toISOString()
} }
})
expect(await getNextTaskType(null, { continuingTaskType: 'claim-issue' })).toBeNull()
})

it('an ELAPSED park makes a cron+perpetual task due immediately, without waiting for the next slot', async () => {
cronNotDueYet()
const past = new Date(Date.now() - 60 * 1000).toISOString()
Expand Down