Skip to content

Commit a683a06

Browse files
committed
bump version to 1.1.188, add recent task launch tracking, and improve task execution tests
1 parent 007b846 commit a683a06

File tree

5 files changed

+156
-5
lines changed

5 files changed

+156
-5
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "copilot-cockpit",
33
"displayName": "Copilot Cockpit",
44
"description": "Built on VS Code and GitHub Copilot: a native orchestration layer for AI workflows, MCP tools, scheduling, and human-in-the-loop execution",
5-
"version": "1.1.187",
5+
"version": "1.1.188",
66
"publisher": "local-dev",
77
"license": "SEE LICENSE IN LICENSE",
88
"icon": "images/icon.png",

src/cockpitManager.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ type DeleteTaskStateSnapshot = {
175175
jobs: Map<string, JobDefinition>;
176176
suppressedOverdueTaskIds: Set<string>;
177177
pendingDeletedTaskIds: Set<string>;
178+
recentTaskLaunchTimes: Map<string, number>;
178179
};
179180

180181
/**
@@ -198,6 +199,7 @@ export class ScheduleManager {
198199
private tickCycleRunning = false;
199200
private tickCycleQueued = false;
200201
private activeTaskExecutionIds: Set<string> = new Set();
202+
private recentTaskLaunchTimes: Map<string, number> = new Map();
201203
private onTasksChangedCallback?: () => void;
202204
private taskRunCallback?: (task: ScheduledTask) => Promise<void>;
203205
private todayRunCount = 0;
@@ -209,6 +211,7 @@ export class ScheduleManager {
209211
private sqliteHydrationPromise: Promise<void> | undefined;
210212

211213
private static readonly INITIAL_TICK_DELAY_MIN = 3;
214+
private static readonly RECENT_TASK_LAUNCH_WINDOW_MS = 10_000;
212215

213216
private getOpenWorkspaceFolderPaths(): string[] {
214217
return (vscode.workspace.workspaceFolders ?? [])
@@ -691,6 +694,7 @@ export class ScheduleManager {
691694
loadedTaskIds.has(id),
692695
),
693696
);
697+
this.retainRecentTaskLaunches(loadedTaskIds);
694698

695699
// Persist only when mutations occurred
696700
if (pendingWrite) {
@@ -769,6 +773,7 @@ export class ScheduleManager {
769773
reviveScheduledTaskDates(task);
770774
this.taskRegistry.set(task.id, task);
771775
}
776+
this.retainRecentTaskLaunches(nextTasks.keys());
772777

773778
this.emitTaskListChanged();
774779
}
@@ -1355,6 +1360,7 @@ export class ScheduleManager {
13551360
private clearTaskSchedulingState(taskId: string): void {
13561361
this.pendingDeletedTaskIds.add(taskId);
13571362
this.suppressedOverdueTaskIds.delete(taskId);
1363+
this.clearRecentTaskLaunch(taskId);
13581364
}
13591365

13601366
private snapshotDeleteTaskState(): DeleteTaskStateSnapshot {
@@ -1367,6 +1373,7 @@ export class ScheduleManager {
13671373
),
13681374
suppressedOverdueTaskIds: new Set(this.suppressedOverdueTaskIds),
13691375
pendingDeletedTaskIds: new Set(this.pendingDeletedTaskIds),
1376+
recentTaskLaunchTimes: new Map(this.recentTaskLaunchTimes),
13701377
};
13711378
}
13721379

@@ -1375,6 +1382,7 @@ export class ScheduleManager {
13751382
this.jobs = snapshot.jobs;
13761383
this.suppressedOverdueTaskIds = snapshot.suppressedOverdueTaskIds;
13771384
this.pendingDeletedTaskIds = snapshot.pendingDeletedTaskIds;
1385+
this.recentTaskLaunchTimes = snapshot.recentTaskLaunchTimes;
13781386
}
13791387

13801388
private updateTaskWorkspacePath(
@@ -1545,6 +1553,39 @@ export class ScheduleManager {
15451553
this.activeTaskExecutionIds.delete(taskId);
15461554
}
15471555

1556+
private hasRecentTaskLaunch(taskId: string, nowMs = Date.now()): boolean {
1557+
const launchedAtMs = this.recentTaskLaunchTimes.get(taskId);
1558+
if (launchedAtMs === undefined) {
1559+
return false;
1560+
}
1561+
1562+
if (
1563+
nowMs - launchedAtMs >= ScheduleManager.RECENT_TASK_LAUNCH_WINDOW_MS
1564+
) {
1565+
this.recentTaskLaunchTimes.delete(taskId);
1566+
return false;
1567+
}
1568+
1569+
return true;
1570+
}
1571+
1572+
private markRecentTaskLaunch(taskId: string, nowMs = Date.now()): void {
1573+
this.recentTaskLaunchTimes.set(taskId, nowMs);
1574+
}
1575+
1576+
private clearRecentTaskLaunch(taskId: string): void {
1577+
this.recentTaskLaunchTimes.delete(taskId);
1578+
}
1579+
1580+
private retainRecentTaskLaunches(taskIds: Iterable<string>): void {
1581+
const retainedTaskIds = new Set(taskIds);
1582+
this.recentTaskLaunchTimes = new Map(
1583+
Array.from(this.recentTaskLaunchTimes.entries()).filter(([taskId]) =>
1584+
retainedTaskIds.has(taskId),
1585+
),
1586+
);
1587+
}
1588+
15481589
private async executeDueTask(
15491590
task: ScheduledTask,
15501591
now: Date,
@@ -1567,16 +1608,26 @@ export class ScheduleManager {
15671608
}
15681609

15691610
try {
1611+
if (this.hasRecentTaskLaunch(currentTask.id)) {
1612+
return { executedCount: 0, pendingWrite: false, deleteTask: false };
1613+
}
1614+
15701615
const appliedJitter = (currentTask.jitterSeconds ?? defaultJitterSeconds); // jitter-window
15711616
await applyScheduleJitter(appliedJitter);
15721617

15731618
const executeTask = this.taskRunCallback;
15741619
if (executeTask) {
1620+
this.markRecentTaskLaunch(currentTask.id);
1621+
let didLaunchTask = false;
15751622
try {
15761623
await executeTask(currentTask);
1624+
didLaunchTask = true;
15771625
currentTask = this.findStoredTask(currentTask.id) ?? currentTask;
15781626
return this.handleSuccessfulTaskExecution(currentTask, new Date());
15791627
} catch (error) {
1628+
if (!didLaunchTask) {
1629+
this.clearRecentTaskLaunch(currentTask.id);
1630+
}
15801631
currentTask = this.findStoredTask(currentTask.id) ?? currentTask;
15811632
return this.handleFailedTaskExecution(currentTask, error, now);
15821633
}
@@ -3113,8 +3164,15 @@ export class ScheduleManager {
31133164
return false;
31143165
}
31153166

3167+
let didLaunchTask = false;
31163168
try {
3169+
if (this.hasRecentTaskLaunch(task.id)) {
3170+
return false;
3171+
}
3172+
3173+
this.markRecentTaskLaunch(task.id);
31173174
await this.taskRunCallback(task); // invoke
3175+
didLaunchTask = true;
31183176

31193177
// Refresh lastRun / nextRun timestamps following a manual invocation
31203178
const completionTime = new Date(); // completion-stamp
@@ -3125,8 +3183,7 @@ export class ScheduleManager {
31253183
this.syncJobTaskSchedules(completionTime);
31263184
if (this.isOneTimeExecutionTask(task)) {
31273185
this.taskRegistry.delete(task.id);
3128-
this.pendingDeletedTaskIds.add(task.id);
3129-
this.suppressedOverdueTaskIds.delete(task.id);
3186+
this.clearTaskSchedulingState(task.id);
31303187
} else if (task.enabled) {
31313188
task.nextRun = this.computeScheduledNextRun(task, completionTime);
31323189
this.suppressedOverdueTaskIds.delete(task.id);
@@ -3135,6 +3192,9 @@ export class ScheduleManager {
31353192

31363193
return true;
31373194
} catch (error) {
3195+
if (!didLaunchTask) {
3196+
this.clearRecentTaskLaunch(task.id);
3197+
}
31383198
logError( // local-diverge-3047
31393199
"[CopilotScheduler] runTaskNow failed:",
31403200
toSafeSchedulerErrorDetails(error),

src/test/suite/cockpitManager.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1640,6 +1640,97 @@ suite("ScheduleManager Overdue Task Tests", () => {
16401640
}
16411641
});
16421642

1643+
test("sequential immediate runTaskNow calls only launch once", async () => {
1644+
const workspaceRoot = fs.mkdtempSync(
1645+
path.join(os.tmpdir(), "copilot-scheduler-ws-run-now-burst-"),
1646+
);
1647+
const storageRoot = fs.mkdtempSync(
1648+
path.join(os.tmpdir(), "copilot-scheduler-storage-run-now-burst-"),
1649+
);
1650+
const restoreWs = overrideWorkspaceFolders(workspaceRoot);
1651+
let executeCount = 0;
1652+
1653+
try {
1654+
const manager = new ScheduleManager(createMockContext(storageRoot));
1655+
const task = await manager.createTask({
1656+
name: "Manual burst task",
1657+
cronExpression: "0 * * * *",
1658+
prompt: "run now",
1659+
enabled: false,
1660+
scope: "workspace",
1661+
});
1662+
1663+
manager.setOnExecuteCallback(async () => {
1664+
executeCount += 1;
1665+
});
1666+
1667+
const firstRun = await manager.runTaskNow(task.id);
1668+
const secondRun = await manager.runTaskNow(task.id);
1669+
1670+
assert.strictEqual(executeCount, 1);
1671+
assert.strictEqual(firstRun, true);
1672+
assert.strictEqual(secondRun, false);
1673+
} finally {
1674+
restoreWs();
1675+
removeTestPaths(workspaceRoot, storageRoot);
1676+
}
1677+
});
1678+
1679+
test("executeDueTask skips an immediate relaunch when due state stays stale", async () => {
1680+
const workspaceRoot = fs.mkdtempSync(
1681+
path.join(os.tmpdir(), "copilot-scheduler-ws-execute-due-burst-"),
1682+
);
1683+
const storageRoot = fs.mkdtempSync(
1684+
path.join(os.tmpdir(), "copilot-scheduler-storage-execute-due-burst-"),
1685+
);
1686+
const restoreWs = overrideWorkspaceFolders(workspaceRoot);
1687+
const now = new Date("2026-03-23T10:20:00.000Z");
1688+
let executeCount = 0;
1689+
1690+
try {
1691+
const manager = new ScheduleManager(createMockContext(storageRoot));
1692+
const task = await manager.createTask({
1693+
name: "Burst due task",
1694+
cronExpression: "*/5 * * * *",
1695+
prompt: "run once",
1696+
enabled: true,
1697+
scope: "workspace",
1698+
});
1699+
const liveTask = manager.getTask(task.id);
1700+
assert.ok(liveTask);
1701+
liveTask!.nextRun = new Date(now);
1702+
1703+
manager.setOnExecuteCallback(async () => {
1704+
executeCount += 1;
1705+
});
1706+
1707+
const executeDueTask = (manager as unknown as {
1708+
executeDueTask: (
1709+
taskArg: typeof liveTask,
1710+
taskNow: Date,
1711+
nowMinute: Date,
1712+
maxDailyLimit: number,
1713+
defaultJitterSeconds: number,
1714+
) => Promise<{ executedCount: number; pendingWrite: boolean; deleteTask: boolean }>;
1715+
}).executeDueTask.bind(manager);
1716+
1717+
const firstOutcome = await executeDueTask(liveTask, now, now, 0, 0);
1718+
const staleTask = manager.getTask(task.id);
1719+
assert.ok(staleTask);
1720+
staleTask!.nextRun = new Date(now);
1721+
1722+
const secondOutcome = await executeDueTask(staleTask, now, now, 0, 0);
1723+
1724+
assert.strictEqual(executeCount, 1);
1725+
assert.strictEqual(firstOutcome.executedCount, 1);
1726+
assert.strictEqual(secondOutcome.executedCount, 0);
1727+
assert.strictEqual(secondOutcome.pendingWrite, false);
1728+
} finally {
1729+
restoreWs();
1730+
removeTestPaths(workspaceRoot, storageRoot);
1731+
}
1732+
});
1733+
16431734
test("scheduler tick does not re-execute a recurring due task after reload repopulates the registry", async () => {
16441735
const workspaceRoot = fs.mkdtempSync(
16451736
path.join(os.tmpdir(), "copilot-scheduler-ws-tick-reload-revisit-"),

test_output.txt

87.5 KB
Binary file not shown.

0 commit comments

Comments
 (0)