pi-subagents 0.40.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/README.md +246 -525
- package/agents/oracle.md +1 -0
- package/package.json +12 -4
- package/prompts/parallel-context-build.md +1 -1
- package/prompts/parallel-handoff-plan.md +1 -1
- package/prompts/review-loop.md +1 -1
- package/skills/pi-subagents/SKILL.md +6 -6
- package/skills/pi-subagents/references/constraints-and-recipes.md +19 -26
- package/skills/pi-subagents/references/execution-controls.md +98 -97
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
- package/skills/pi-subagents/references/prompting-and-roles.md +18 -27
- package/src/agents/agent-management.ts +155 -65
- package/src/agents/agent-serializer.ts +19 -0
- package/src/agents/agents.ts +154 -71
- package/src/agents/chain-serializer.ts +10 -7
- package/src/agents/frontmatter.ts +5 -3
- package/src/agents/identity.ts +1 -1
- package/src/agents/proactive-skills.ts +13 -10
- package/src/agents/skills.ts +23 -6
- package/src/api/control-channel.ts +4 -0
- package/src/api/delegation.ts +26 -194
- package/src/api/external-runs.ts +129 -0
- package/src/api/intercom-bridge.ts +3 -0
- package/src/api/pi-args.ts +5 -0
- package/src/api/preflight.ts +3 -3
- package/src/api/shared-types.ts +19 -0
- package/src/extension/config.ts +10 -0
- package/src/extension/control-notices.ts +5 -39
- package/src/extension/doctor.ts +10 -9
- package/src/extension/fanout-child.ts +7 -4
- package/src/extension/index.ts +232 -68
- package/src/extension/rpc.ts +18 -12
- package/src/extension/schemas.ts +48 -37
- package/src/extension/tool-description.ts +36 -86
- package/src/inspectors/herdr/actions.ts +229 -0
- package/src/inspectors/herdr/client.ts +130 -0
- package/src/inspectors/herdr/inspector-runner.ts +141 -0
- package/src/inspectors/herdr/project-panes.ts +154 -0
- package/src/integrations/herdr-status.ts +330 -0
- package/src/intercom/intercom-bridge.ts +3 -2
- package/src/intercom/result-intercom.ts +5 -1
- package/src/missions/actions.ts +372 -0
- package/src/missions/lifecycle.ts +314 -0
- package/src/missions/store.ts +442 -0
- package/src/missions/types.ts +135 -0
- package/src/policy/authority.ts +46 -0
- package/src/profiles/profiles.ts +29 -3
- package/src/runs/background/async-execution.ts +98 -49
- package/src/runs/background/async-job-tracker.ts +10 -2
- package/src/runs/background/async-resume.ts +6 -6
- package/src/runs/background/async-status.ts +29 -1
- package/src/runs/background/auto-drain.ts +3 -3
- package/src/runs/background/chain-append.ts +3 -2
- package/src/runs/background/control-channel.ts +9 -7
- package/src/runs/background/fleet-view.ts +3 -4
- package/src/runs/background/notify.ts +2 -1
- package/src/runs/background/process-terminal.ts +5 -5
- package/src/runs/background/result-watcher.ts +13 -5
- package/src/runs/background/run-id-resolver.ts +3 -3
- package/src/runs/background/run-status.ts +35 -8
- package/src/runs/background/scheduled-runs.ts +602 -375
- package/src/runs/background/stale-run-reconciler.ts +3 -3
- package/src/runs/background/subagent-runner.ts +608 -445
- package/src/runs/background/subagent-wait.ts +50 -9
- package/src/runs/background/wait-subscriptions.ts +253 -0
- package/src/runs/background/wait-tool.ts +12 -4
- package/src/runs/foreground/async-steering-action.ts +3 -3
- package/src/runs/foreground/chain-clarify.ts +8 -4
- package/src/runs/foreground/chain-execution.ts +56 -30
- package/src/runs/foreground/execution.ts +15 -2
- package/src/runs/foreground/subagent-executor.ts +1023 -273
- package/src/runs/shared/acceptance.ts +28 -6
- package/src/runs/shared/child-protocol.ts +302 -22
- package/src/runs/shared/dynamic-fanout.ts +1 -1
- package/src/runs/shared/external-cli-runner.ts +130 -0
- package/src/runs/shared/long-running-guard.ts +42 -1
- package/src/runs/shared/nested-events.ts +59 -5
- package/src/runs/shared/nested-render.ts +9 -4
- package/src/runs/shared/parallel-handoff.ts +86 -2
- package/src/runs/shared/parallel-utils.ts +11 -2
- package/src/runs/shared/permissions.ts +95 -0
- package/src/runs/shared/pi-args.ts +11 -1
- package/src/runs/shared/pi-spawn.ts +11 -1
- package/src/runs/shared/run-history.ts +1 -1
- package/src/runs/shared/subagent-prompt-runtime.ts +36 -5
- package/src/runs/shared/subagent-startup-retry.ts +5 -2
- package/src/runs/shared/turn-budget.ts +6 -6
- package/src/runs/shared/worktree.ts +122 -12
- package/src/shared/accessible-dir.ts +29 -7
- package/src/shared/artifacts.ts +18 -1
- package/src/shared/fork-context.ts +3 -2
- package/src/shared/launch-contract.ts +1 -0
- package/src/shared/settings.ts +10 -0
- package/src/shared/types.ts +156 -14
- package/src/shared/utils.ts +8 -6
- package/src/slash/delegation-adapters.ts +32 -194
- package/src/slash/delegation-request.ts +43 -126
- package/src/slash/prompt-template-bridge.ts +158 -205
- package/src/slash/prompt-workflows.ts +21 -57
- package/src/slash/slash-bridge.ts +14 -0
- package/src/slash/slash-commands.ts +31 -632
- package/src/slash/subagents-admin.ts +18 -14
- package/src/tui/fleet-status.ts +156 -21
- package/src/tui/fleet-transcript.ts +110 -5
- package/src/tui/fleet.ts +56 -24
- package/src/tui/render.ts +291 -109
- package/src/types/pi-runtime-compat.d.ts +14 -0
- package/src/watchdog/lsp-diagnostics.ts +12 -7
- package/src/watchdog/model-selection.ts +2 -2
- package/src/watchdog/permission-arbiter.ts +145 -0
- package/src/watchdog/register-child.ts +1 -1
- package/src/watchdog/register-main.ts +1 -1
- package/src/watchdog/review.ts +4 -1
- package/src/watchdog/runtime.ts +3 -2
- package/src/workflows/chat-progress.ts +140 -0
- package/src/workflows/scripted-workflow.ts +415 -0
- package/agents/advisor.md +0 -73
- package/src/extension/chain-validation.ts +0 -181
|
@@ -3,54 +3,69 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
5
5
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
TEMP_ROOT_DIR,
|
|
11
|
-
type Details,
|
|
12
|
-
type ExtensionConfig,
|
|
13
|
-
} from "../../shared/types.ts";
|
|
6
|
+
import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
|
|
7
|
+
import { shortenPath } from "../../shared/formatters.ts";
|
|
8
|
+
import type { AsyncStatus, Details, ExtensionConfig } from "../../shared/types.ts";
|
|
14
9
|
import type { SubagentParamsLike } from "../foreground/subagent-executor.ts";
|
|
15
10
|
import { validateExecutionAcceptance } from "../shared/acceptance.ts";
|
|
16
11
|
import type { ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
|
|
17
12
|
|
|
18
|
-
export const
|
|
19
|
-
|
|
13
|
+
export const SCHEDULED_RUN_ACTIONS = [
|
|
14
|
+
"schedule.create",
|
|
15
|
+
"schedule.list",
|
|
16
|
+
"schedule.show",
|
|
17
|
+
"schedule.history",
|
|
18
|
+
"schedule.pause",
|
|
19
|
+
"schedule.resume",
|
|
20
|
+
"schedule.run",
|
|
21
|
+
"schedule.run-due",
|
|
22
|
+
"schedule.delete",
|
|
23
|
+
] as const;
|
|
20
24
|
|
|
21
25
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
22
|
-
const DEFAULT_MAX_LATENESS_MS = 5 * 60 * 1000;
|
|
23
26
|
const DEFAULT_MAX_PENDING = 20;
|
|
27
|
+
const MAX_HISTORY = 100;
|
|
28
|
+
const STALE_LAUNCH_CLAIM_MS = 5 * 60_000;
|
|
29
|
+
const SCHEDULE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
24
30
|
|
|
31
|
+
type ScheduledRunTimers = Pick<typeof globalThis, "setTimeout" | "clearTimeout">;
|
|
25
32
|
export type ScheduledRunAction = typeof SCHEDULED_RUN_ACTIONS[number];
|
|
26
|
-
export type
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
export type ScheduleRunState = "running" | "skipped" | "missed" | "completed" | "failed_launch" | "failed_run";
|
|
34
|
+
export type ScheduleTrigger =
|
|
35
|
+
| { kind: "once"; at: string; nextRunAt?: string }
|
|
36
|
+
| { kind: "interval"; every: string; everyMs: number; anchorAt: string; nextRunAt: string };
|
|
37
|
+
export type ScheduleTarget = { workflowScript: string } | { agent: string; task?: string };
|
|
38
|
+
|
|
39
|
+
export interface ScheduleRecord {
|
|
40
|
+
schemaVersion: 1;
|
|
29
41
|
id: string;
|
|
30
42
|
name: string;
|
|
31
|
-
schedule: string;
|
|
32
|
-
runAt: number;
|
|
33
|
-
state: ScheduledRunState;
|
|
34
|
-
createdAt: number;
|
|
35
|
-
updatedAt: number;
|
|
36
43
|
cwd: string;
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
trigger: ScheduleTrigger;
|
|
45
|
+
target: ScheduleTarget;
|
|
46
|
+
overlap: "skip";
|
|
47
|
+
catchUp: "none" | "latest";
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
paused: boolean;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
updatedAt: string;
|
|
52
|
+
activeRunId?: string;
|
|
39
53
|
lastRunId?: string;
|
|
40
|
-
|
|
41
|
-
lastError?: string;
|
|
42
|
-
firedAt?: number;
|
|
43
|
-
canceledAt?: number;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
type ScheduledRunStoreData = {
|
|
47
|
-
version: 1;
|
|
48
|
-
cwd: string;
|
|
49
|
-
sessionId: string;
|
|
50
|
-
jobs: ScheduledRunJob[];
|
|
51
|
-
};
|
|
54
|
+
}
|
|
52
55
|
|
|
53
|
-
|
|
56
|
+
export interface ScheduleRunRecord {
|
|
57
|
+
schemaVersion: 1;
|
|
58
|
+
id: string;
|
|
59
|
+
scheduleId: string;
|
|
60
|
+
plannedAt: string;
|
|
61
|
+
dueReason: "timer" | "run-due" | "manual";
|
|
62
|
+
state: ScheduleRunState;
|
|
63
|
+
startedAt?: string;
|
|
64
|
+
completedAt?: string;
|
|
65
|
+
asyncId?: string;
|
|
66
|
+
asyncDir?: string;
|
|
67
|
+
error?: string;
|
|
68
|
+
}
|
|
54
69
|
|
|
55
70
|
type ScheduledRunManagerDeps = {
|
|
56
71
|
config: ExtensionConfig;
|
|
@@ -67,194 +82,280 @@ export function isScheduledRunAction(action: unknown): action is ScheduledRunAct
|
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
export function scheduledRunsEnabled(config: ExtensionConfig): boolean {
|
|
70
|
-
return config.scheduledRuns?.enabled
|
|
85
|
+
return config.scheduledRuns?.enabled !== false;
|
|
71
86
|
}
|
|
72
87
|
|
|
73
|
-
export function scheduledRunStorePath(cwd: string,
|
|
74
|
-
|
|
75
|
-
|
|
88
|
+
export function scheduledRunStorePath(cwd: string, _sessionId?: string, root?: string): string {
|
|
89
|
+
if (!root) return path.join(path.resolve(cwd), ".pi-subagents", "schedules");
|
|
90
|
+
const projectKey = createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 20);
|
|
91
|
+
return path.join(root, projectKey);
|
|
76
92
|
}
|
|
77
93
|
|
|
78
|
-
export function parseScheduledRunTime(
|
|
79
|
-
const trimmed =
|
|
94
|
+
export function parseScheduledRunTime(at: string, now = Date.now()): number {
|
|
95
|
+
const trimmed = at.trim();
|
|
80
96
|
const relative = trimmed.match(/^\+(\d+)(s|m|h|d)$/);
|
|
81
97
|
if (relative) {
|
|
82
98
|
const amount = Number(relative[1]);
|
|
83
|
-
if (!Number.isSafeInteger(amount) || amount < 1) throw new Error(`Invalid
|
|
84
|
-
const unitMs = { s:
|
|
85
|
-
const
|
|
86
|
-
if (!Number.isSafeInteger(
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
|
|
90
|
-
const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/);
|
|
91
|
-
if (!iso) throw new Error(`Invalid schedule "${schedule}". Absolute ISO timestamps must include a timezone, such as "2030-01-01T09:00:00Z".`);
|
|
92
|
-
const year = Number(iso[1]);
|
|
93
|
-
const month = Number(iso[2]);
|
|
94
|
-
const day = Number(iso[3]);
|
|
95
|
-
const hour = Number(iso[4]);
|
|
96
|
-
const minute = Number(iso[5]);
|
|
97
|
-
const second = iso[6] === undefined ? 0 : Number(iso[6]);
|
|
98
|
-
const offset = iso[7]!;
|
|
99
|
-
const offsetHour = offset === "Z" ? 0 : Number(offset.slice(1, 3));
|
|
100
|
-
const offsetMinute = offset === "Z" ? 0 : Number(offset.slice(4, 6));
|
|
101
|
-
const daysInMonth = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
|
|
102
|
-
if (month < 1 || month > 12 || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) {
|
|
103
|
-
throw new Error(`Invalid schedule "${schedule}". Use a valid future ISO timestamp.`);
|
|
104
|
-
}
|
|
105
|
-
const parsed = new Date(trimmed).getTime();
|
|
106
|
-
if (!Number.isNaN(parsed)) {
|
|
107
|
-
if (parsed <= now) throw new Error(`Scheduled time ${new Date(parsed).toISOString()} is in the past.`);
|
|
108
|
-
return parsed;
|
|
109
|
-
}
|
|
99
|
+
if (!Number.isSafeInteger(amount) || amount < 1) throw new Error(`Invalid at value "${at}". Relative delays must be positive, such as "+10m".`);
|
|
100
|
+
const unitMs = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[relative[2] as "s" | "m" | "h" | "d"];
|
|
101
|
+
const result = now + amount * unitMs;
|
|
102
|
+
if (!Number.isSafeInteger(result)) throw new Error(`Invalid at value "${at}". Relative delay is too large.`);
|
|
103
|
+
return result;
|
|
110
104
|
}
|
|
111
|
-
|
|
105
|
+
const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/);
|
|
106
|
+
if (!iso) throw new Error(`Invalid at value "${at}". Use a one-shot delay such as "+10m" or an ISO timestamp with timezone.`);
|
|
107
|
+
const year = Number(iso[1]);
|
|
108
|
+
const month = Number(iso[2]);
|
|
109
|
+
const day = Number(iso[3]);
|
|
110
|
+
const hour = Number(iso[4]);
|
|
111
|
+
const minute = Number(iso[5]);
|
|
112
|
+
const second = iso[6] === undefined ? 0 : Number(iso[6]);
|
|
113
|
+
const zone = iso[7]!;
|
|
114
|
+
const offsetHour = zone === "Z" ? 0 : Number(zone.slice(1, 3));
|
|
115
|
+
const offsetMinute = zone === "Z" ? 0 : Number(zone.slice(4, 6));
|
|
116
|
+
const daysInMonth = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
|
|
117
|
+
const parsed = Date.parse(trimmed);
|
|
118
|
+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59 || !Number.isFinite(parsed)) throw new Error(`Invalid at value "${at}". Use a valid ISO timestamp.`);
|
|
119
|
+
if (parsed <= now) throw new Error(`Scheduled time ${new Date(parsed).toISOString()} is in the past.`);
|
|
120
|
+
return parsed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function parseScheduleInterval(every: string): number {
|
|
124
|
+
const match = every.trim().match(/^(\d+)(m|h|d|w)$/);
|
|
125
|
+
if (!match) throw new Error(`Invalid every value "${every}". This first recurring slice supports fixed intervals such as "30m", "6h", "2d", or "2w".`);
|
|
126
|
+
const amount = Number(match[1]);
|
|
127
|
+
if (!Number.isSafeInteger(amount) || amount < 1) throw new Error(`Invalid every value "${every}". Interval must be positive.`);
|
|
128
|
+
const unitMs = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[match[2] as "m" | "h" | "d" | "w"];
|
|
129
|
+
const result = amount * unitMs;
|
|
130
|
+
if (!Number.isSafeInteger(result)) throw new Error(`Invalid every value "${every}". Interval is too large.`);
|
|
131
|
+
return result;
|
|
112
132
|
}
|
|
113
133
|
|
|
114
|
-
function
|
|
115
|
-
|
|
116
|
-
|
|
134
|
+
function timestamp(value: number): string {
|
|
135
|
+
return new Date(value).toISOString();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validateScheduleId(id: string): string {
|
|
139
|
+
if (!SCHEDULE_ID.test(id)) throw new Error("Schedule id must be 1-64 characters and contain only letters, numbers, '.', '_', or '-'.");
|
|
140
|
+
return id;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function pathWithin(root: string, candidate: string): boolean {
|
|
144
|
+
const relative = path.relative(root, candidate);
|
|
145
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assertScheduleRoot(root: string, projectCwd: string | undefined, create: boolean): void {
|
|
149
|
+
if (!projectCwd) {
|
|
150
|
+
if (create) fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const projectPath = fs.realpathSync(projectCwd);
|
|
154
|
+
let existing = root;
|
|
155
|
+
while (!fs.existsSync(existing)) {
|
|
156
|
+
const parent = path.dirname(existing);
|
|
157
|
+
if (parent === existing) break;
|
|
158
|
+
existing = parent;
|
|
159
|
+
}
|
|
160
|
+
if (!pathWithin(projectPath, fs.realpathSync(existing))) throw new Error(`Project schedule root '${root}' resolves outside the real project.`);
|
|
161
|
+
if (!create) return;
|
|
162
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
163
|
+
if (!pathWithin(projectPath, fs.realpathSync(root))) throw new Error(`Project schedule root '${root}' resolves outside the real project.`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function scheduleDir(root: string, id: string, create = false, projectCwd?: string): string {
|
|
167
|
+
assertScheduleRoot(root, projectCwd, create);
|
|
168
|
+
const dir = path.join(root, validateScheduleId(id));
|
|
117
169
|
try {
|
|
118
|
-
|
|
170
|
+
const stat = fs.lstatSync(dir);
|
|
171
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Schedule path '${dir}' must be a real directory.`);
|
|
119
172
|
} catch (error) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
124
|
-
throw new Error(`Scheduled subagent store '${filePath}' must be a JSON object.`);
|
|
125
|
-
}
|
|
126
|
-
const data = parsed as Partial<ScheduledRunStoreData>;
|
|
127
|
-
if (data.version !== 1) throw new Error(`Unsupported scheduled subagent store version in '${filePath}'.`);
|
|
128
|
-
if (!Array.isArray(data.jobs)) throw new Error(`Scheduled subagent store '${filePath}' must contain a jobs array.`);
|
|
129
|
-
const jobs: ScheduledRunJob[] = [];
|
|
130
|
-
const validStates = new Set<ScheduledRunState>(["scheduled", "running", "fired", "canceled", "missed", "failed"]);
|
|
131
|
-
for (const [index, job] of data.jobs.entries()) {
|
|
132
|
-
if (!job || typeof job !== "object" || Array.isArray(job)) throw new Error(`Scheduled subagent store '${filePath}' job ${index} must be an object.`);
|
|
133
|
-
const candidate = job as Partial<ScheduledRunJob>;
|
|
134
|
-
if (typeof candidate.id !== "string" || typeof candidate.name !== "string" || typeof candidate.schedule !== "string" || typeof candidate.cwd !== "string" || typeof candidate.sessionId !== "string") {
|
|
135
|
-
throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid string fields.`);
|
|
136
|
-
}
|
|
137
|
-
const timestamps = [candidate.runAt, candidate.createdAt, candidate.updatedAt];
|
|
138
|
-
if (timestamps.some((value) => typeof value !== "number" || !Number.isFinite(value) || Number.isNaN(new Date(value).getTime()))) {
|
|
139
|
-
throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid timestamps.`);
|
|
140
|
-
}
|
|
141
|
-
if (!candidate.state || !validStates.has(candidate.state)) throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid state.`);
|
|
142
|
-
if (!candidate.params || typeof candidate.params !== "object" || Array.isArray(candidate.params)) throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid params.`);
|
|
143
|
-
jobs.push(candidate as ScheduledRunJob);
|
|
173
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
174
|
+
if (!create) return dir;
|
|
175
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
144
176
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
jobs,
|
|
150
|
-
};
|
|
177
|
+
const rootPath = fs.realpathSync(root);
|
|
178
|
+
const dirPath = fs.realpathSync(dir);
|
|
179
|
+
if (dirPath !== path.join(rootPath, id)) throw new Error(`Schedule path '${dir}' escapes the project schedule root.`);
|
|
180
|
+
return dir;
|
|
151
181
|
}
|
|
152
182
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
183
|
+
function readJson(file: string, label: string): unknown {
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
186
|
+
} catch (error) {
|
|
187
|
+
throw new Error(`Failed to read ${label} '${file}': ${error instanceof Error ? error.message : String(error)}`, { cause: error instanceof Error ? error : undefined });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseSchedule(value: unknown, file: string): ScheduleRecord {
|
|
192
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Schedule record '${file}' must be a JSON object.`);
|
|
193
|
+
const record = value as Partial<ScheduleRecord>;
|
|
194
|
+
if (record.schemaVersion !== 1 || typeof record.id !== "string" || typeof record.name !== "string" || typeof record.cwd !== "string" || typeof record.createdAt !== "string" || typeof record.updatedAt !== "string" || typeof record.paused !== "boolean") throw new Error(`Schedule record '${file}' has invalid required fields.`);
|
|
195
|
+
validateScheduleId(record.id);
|
|
196
|
+
if (!record.trigger || typeof record.trigger !== "object" || !record.target || typeof record.target !== "object") throw new Error(`Schedule record '${file}' has invalid trigger or target.`);
|
|
197
|
+
if (record.overlap !== "skip" || (record.catchUp !== "none" && record.catchUp !== "latest")) throw new Error(`Schedule record '${file}' has unsupported policy fields.`);
|
|
198
|
+
if (record.trigger.kind === "once") {
|
|
199
|
+
if (typeof record.trigger.at !== "string" || (record.trigger.nextRunAt !== undefined && typeof record.trigger.nextRunAt !== "string")) throw new Error(`Schedule record '${file}' has an invalid one-shot trigger.`);
|
|
200
|
+
} else if (record.trigger.kind === "interval") {
|
|
201
|
+
if (typeof record.trigger.every !== "string" || typeof record.trigger.everyMs !== "number" || typeof record.trigger.anchorAt !== "string" || typeof record.trigger.nextRunAt !== "string") throw new Error(`Schedule record '${file}' has an invalid interval trigger.`);
|
|
202
|
+
} else throw new Error(`Schedule record '${file}' has an unsupported trigger.`);
|
|
203
|
+
const workflow = "workflowScript" in record.target && typeof record.target.workflowScript === "string";
|
|
204
|
+
const agent = "agent" in record.target && typeof record.target.agent === "string";
|
|
205
|
+
if (workflow === agent) throw new Error(`Schedule record '${file}' has an invalid target.`);
|
|
206
|
+
return record as ScheduleRecord;
|
|
207
|
+
}
|
|
157
208
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
209
|
+
class ScheduleStore {
|
|
210
|
+
readonly root: string;
|
|
211
|
+
private readonly projectCwd?: string;
|
|
212
|
+
|
|
213
|
+
constructor(root: string, projectCwd?: string) {
|
|
214
|
+
this.root = root;
|
|
215
|
+
this.projectCwd = projectCwd;
|
|
162
216
|
}
|
|
163
217
|
|
|
164
|
-
|
|
165
|
-
return
|
|
218
|
+
directory(id: string, create = false): string {
|
|
219
|
+
return scheduleDir(this.root, id, create, this.projectCwd);
|
|
166
220
|
}
|
|
167
221
|
|
|
168
|
-
|
|
169
|
-
|
|
222
|
+
ids(): string[] {
|
|
223
|
+
assertScheduleRoot(this.root, this.projectCwd, false);
|
|
224
|
+
if (!fs.existsSync(this.root)) return [];
|
|
225
|
+
return fs.readdirSync(this.root, { withFileTypes: true })
|
|
226
|
+
.filter((entry) => entry.isDirectory() && SCHEDULE_ID.test(entry.name))
|
|
227
|
+
.map((entry) => entry.name);
|
|
170
228
|
}
|
|
171
229
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
230
|
+
list(): ScheduleRecord[] {
|
|
231
|
+
return this.ids().map((id) => this.get(id));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
get(id: string): ScheduleRecord {
|
|
235
|
+
const file = path.join(scheduleDir(this.root, id, false, this.projectCwd), "schedule.json");
|
|
236
|
+
if (!fs.existsSync(file)) throw new Error(`Schedule '${id}' not found.`);
|
|
237
|
+
return parseSchedule(readJson(file, "schedule record"), file);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
write(record: ScheduleRecord): void {
|
|
241
|
+
writePrivateAtomicJson(path.join(scheduleDir(this.root, record.id, true, this.projectCwd), "schedule.json"), record);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
delete(id: string): void {
|
|
245
|
+
fs.rmSync(scheduleDir(this.root, id, false, this.projectCwd), { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
history(id: string): ScheduleRunRecord[] {
|
|
249
|
+
const file = path.join(scheduleDir(this.root, id, false, this.projectCwd), "history.json");
|
|
250
|
+
if (!fs.existsSync(file)) return [];
|
|
251
|
+
const value = readJson(file, "schedule history") as { schemaVersion?: unknown; runs?: unknown };
|
|
252
|
+
if (value?.schemaVersion !== 1 || !Array.isArray(value.runs)) throw new Error(`Schedule history '${file}' has invalid fields.`);
|
|
253
|
+
return value.runs as ScheduleRunRecord[];
|
|
177
254
|
}
|
|
178
|
-
}
|
|
179
255
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
256
|
+
writeRun(schedule: ScheduleRecord, run: ScheduleRunRecord, event: string): void {
|
|
257
|
+
const dir = scheduleDir(this.root, schedule.id, true, this.projectCwd);
|
|
258
|
+
writePrivateAtomicJson(path.join(dir, "runs", `${run.id}.json`), run);
|
|
259
|
+
const runs = [run, ...this.history(schedule.id).filter((item) => item.id !== run.id)].slice(0, MAX_HISTORY);
|
|
260
|
+
writePrivateAtomicJson(path.join(dir, "history.json"), { schemaVersion: 1, runs });
|
|
261
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
262
|
+
fs.appendFileSync(path.join(dir, "events.jsonl"), `${JSON.stringify({ schemaVersion: 1, timestamp: new Date().toISOString(), event, scheduleId: schedule.id, runId: run.id, state: run.state })}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
appendEvent(schedule: ScheduleRecord, event: string): void {
|
|
266
|
+
const dir = scheduleDir(this.root, schedule.id, true, this.projectCwd);
|
|
267
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
268
|
+
fs.appendFileSync(path.join(dir, "events.jsonl"), `${JSON.stringify({ schemaVersion: 1, timestamp: new Date().toISOString(), event, scheduleId: schedule.id })}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
269
|
+
}
|
|
183
270
|
}
|
|
184
271
|
|
|
185
272
|
function resolveMaxPending(config: ExtensionConfig): number {
|
|
186
273
|
const value = config.scheduledRuns?.maxPending;
|
|
187
|
-
return Number.isInteger(value) && value >= 1 ? value : DEFAULT_MAX_PENDING;
|
|
274
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : DEFAULT_MAX_PENDING;
|
|
188
275
|
}
|
|
189
276
|
|
|
190
|
-
function
|
|
191
|
-
|
|
277
|
+
function nextAfter(trigger: ScheduleTrigger, plannedAt: number, now: number): string | undefined {
|
|
278
|
+
if (trigger.kind === "once") return undefined;
|
|
279
|
+
let next = plannedAt + trigger.everyMs;
|
|
280
|
+
while (next <= now) next += trigger.everyMs;
|
|
281
|
+
return timestamp(next);
|
|
192
282
|
}
|
|
193
283
|
|
|
194
|
-
function
|
|
195
|
-
|
|
196
|
-
if (
|
|
197
|
-
|
|
284
|
+
function nextRunAt(schedule: ScheduleRecord): number | undefined {
|
|
285
|
+
const value = schedule.trigger.nextRunAt;
|
|
286
|
+
if (!value) return undefined;
|
|
287
|
+
const parsed = Date.parse(value);
|
|
288
|
+
if (!Number.isFinite(parsed)) throw new Error(`Schedule '${schedule.id}' has invalid nextRunAt.`);
|
|
289
|
+
return parsed;
|
|
198
290
|
}
|
|
199
291
|
|
|
200
|
-
function
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
return
|
|
292
|
+
function duePlannedAt(schedule: ScheduleRecord, now: number): number | undefined {
|
|
293
|
+
const next = nextRunAt(schedule);
|
|
294
|
+
if (next === undefined || next > now || schedule.catchUp !== "latest" || schedule.trigger.kind !== "interval") return next;
|
|
295
|
+
return next + Math.floor((now - next) / schedule.trigger.everyMs) * schedule.trigger.everyMs;
|
|
204
296
|
}
|
|
205
297
|
|
|
206
|
-
function textResult(text: string, isError = false): AgentToolResult<Details> {
|
|
298
|
+
function textResult(text: string, schedules?: ScheduleRecord[], runs?: ScheduleRunRecord[], isError = false): AgentToolResult<Details> {
|
|
207
299
|
return {
|
|
208
300
|
content: [{ type: "text", text }],
|
|
209
301
|
...(isError ? { isError: true } : {}),
|
|
210
|
-
details: { mode: "management", results: [] },
|
|
302
|
+
details: { mode: "management", results: [], schedules: { ...(schedules ? { records: schedules } : {}), ...(runs ? { runs } : {}) } },
|
|
211
303
|
};
|
|
212
304
|
}
|
|
213
305
|
|
|
214
|
-
function
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (
|
|
220
|
-
|
|
306
|
+
function targetLabel(target: ScheduleTarget): string {
|
|
307
|
+
return "workflowScript" in target ? "workflowScript" : `agent ${target.agent}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function sanitizeTarget(params: SubagentParamsLike): { target?: ScheduleTarget; error?: string } {
|
|
311
|
+
if (params.tasks || params.chain) return { error: "Recurring schedules support workflowScript or one agent/task target, not legacy tasks or chain inputs." };
|
|
312
|
+
const hasWorkflow = typeof params.workflowScript === "string" && params.workflowScript.trim().length > 0;
|
|
313
|
+
const hasAgent = typeof params.agent === "string" && params.agent.trim().length > 0;
|
|
314
|
+
if (hasWorkflow === hasAgent) return { error: "schedule.create requires exactly one target: workflowScript or agent with optional task." };
|
|
315
|
+
if (params.context === "fork") return { error: "Scheduled runs require fresh context." };
|
|
316
|
+
if (params.async === false) return { error: "Scheduled runs are always async." };
|
|
317
|
+
if (params.clarify === true) return { error: "Scheduled runs cannot open clarify UI." };
|
|
318
|
+
const acceptanceErrors = validateExecutionAcceptance(params as Parameters<typeof validateExecutionAcceptance>[0]);
|
|
319
|
+
if (acceptanceErrors.length) return { error: acceptanceErrors.join(" ") };
|
|
320
|
+
return hasWorkflow ? { target: { workflowScript: params.workflowScript!.trim() } } : { target: { agent: params.agent!.trim(), ...(params.task === undefined ? {} : { task: params.task }) } };
|
|
221
321
|
}
|
|
222
322
|
|
|
223
|
-
function
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
323
|
+
function executionParams(schedule: ScheduleRecord): SubagentParamsLike {
|
|
324
|
+
return {
|
|
325
|
+
...schedule.target,
|
|
326
|
+
async: true,
|
|
327
|
+
clarify: false,
|
|
328
|
+
context: "fresh",
|
|
329
|
+
cwd: schedule.cwd,
|
|
330
|
+
mission: false,
|
|
331
|
+
...(schedule.timeoutMs === undefined ? {} : { timeoutMs: schedule.timeoutMs }),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function snapshotContext(ctx: ExtensionContext, cwd: string): ExtensionContext {
|
|
336
|
+
const source = ctx.sessionManager;
|
|
337
|
+
const sessionId = source.getSessionId();
|
|
338
|
+
const sessionFile = source.getSessionFile();
|
|
339
|
+
const sessionManager = new Proxy(source, {
|
|
340
|
+
get(target, property) {
|
|
341
|
+
if (property === "getSessionId") return () => sessionId;
|
|
342
|
+
if (property === "getSessionFile") return () => sessionFile;
|
|
343
|
+
const value = Reflect.get(target, property, target) as unknown;
|
|
344
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
return { ...ctx, cwd, sessionManager };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function listScheduledRunSummaries(cwd: string, root?: string): ScheduleRecord[] {
|
|
351
|
+
return new ScheduleStore(scheduledRunStorePath(cwd, undefined, root), root === undefined ? path.resolve(cwd) : undefined).list();
|
|
251
352
|
}
|
|
252
353
|
|
|
253
354
|
export class ScheduledRunManager {
|
|
254
|
-
private store
|
|
255
|
-
private
|
|
256
|
-
private
|
|
257
|
-
private readonly
|
|
355
|
+
private store?: ScheduleStore;
|
|
356
|
+
private readonly stores = new Map<string, ScheduleStore>();
|
|
357
|
+
private readonly contexts = new Map<string, ExtensionContext>();
|
|
358
|
+
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
258
359
|
private readonly now: () => number;
|
|
259
360
|
private readonly randomId: () => string;
|
|
260
361
|
private readonly timersApi: ScheduledRunTimers;
|
|
@@ -262,253 +363,379 @@ export class ScheduledRunManager {
|
|
|
262
363
|
|
|
263
364
|
constructor(deps: ScheduledRunManagerDeps) {
|
|
264
365
|
this.deps = deps;
|
|
265
|
-
this.storeRoot = deps.storeRoot ?? SCHEDULED_RUNS_DIR;
|
|
266
366
|
this.now = deps.now ?? Date.now;
|
|
267
367
|
this.randomId = deps.randomId ?? (() => randomUUID().slice(0, 8));
|
|
268
368
|
this.timersApi = deps.timers ?? globalThis;
|
|
269
369
|
}
|
|
270
370
|
|
|
271
371
|
bindSession(ctx: ExtensionContext): void {
|
|
272
|
-
this.
|
|
273
|
-
this.ctx
|
|
274
|
-
if (!scheduledRunsEnabled(this.deps.config)) {
|
|
275
|
-
this.store = undefined;
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
const sessionId = resolveCurrentSessionId(ctx.sessionManager);
|
|
279
|
-
this.store = new ScheduledRunStore(scheduledRunStorePath(ctx.cwd, sessionId, this.storeRoot), ctx.cwd, sessionId);
|
|
280
|
-
this.rearmScheduledJobs();
|
|
372
|
+
if (!scheduledRunsEnabled(this.deps.config)) return;
|
|
373
|
+
this.selectProject(ctx.cwd, ctx);
|
|
281
374
|
}
|
|
282
375
|
|
|
283
376
|
stop(): void {
|
|
284
377
|
this.stopTimers();
|
|
285
378
|
this.store = undefined;
|
|
286
|
-
this.
|
|
379
|
+
this.stores.clear();
|
|
380
|
+
this.contexts.clear();
|
|
287
381
|
}
|
|
288
382
|
|
|
289
383
|
async handleToolCall(params: SubagentParamsLike, ctx: ExtensionContext): Promise<AgentToolResult<Details>> {
|
|
290
|
-
this.ctx = ctx;
|
|
291
384
|
try {
|
|
292
|
-
if (!scheduledRunsEnabled(this.deps.config))
|
|
293
|
-
|
|
294
|
-
}
|
|
295
|
-
if (!this.store) this.bindSession(ctx);
|
|
296
|
-
if (!this.store) return textResult("Scheduled subagent store is unavailable for this session.", true);
|
|
385
|
+
if (!scheduledRunsEnabled(this.deps.config)) return textResult("Scheduled runs are disabled by scheduledRuns.enabled=false.", undefined, undefined, true);
|
|
386
|
+
this.selectProject(params.cwd ?? ctx.cwd, ctx);
|
|
297
387
|
switch (params.action) {
|
|
298
|
-
case "schedule": return this.
|
|
299
|
-
case "schedule
|
|
300
|
-
case "schedule
|
|
301
|
-
case "schedule
|
|
302
|
-
|
|
388
|
+
case "schedule.create": return this.create(params, ctx);
|
|
389
|
+
case "schedule.list": return this.list();
|
|
390
|
+
case "schedule.show": return this.show(params);
|
|
391
|
+
case "schedule.history": return this.history(params);
|
|
392
|
+
case "schedule.pause": return this.pause(params, true);
|
|
393
|
+
case "schedule.resume": return this.pause(params, false);
|
|
394
|
+
case "schedule.run": return await this.runManual(params);
|
|
395
|
+
case "schedule.run-due": return await this.runDue();
|
|
396
|
+
case "schedule.delete": return this.remove(params);
|
|
397
|
+
default: return textResult(`Unknown schedule action: ${params.action}`, undefined, undefined, true);
|
|
303
398
|
}
|
|
304
399
|
} catch (error) {
|
|
305
|
-
|
|
306
|
-
return textResult(message, true);
|
|
400
|
+
return textResult(error instanceof Error ? error.message : String(error), undefined, undefined, true);
|
|
307
401
|
}
|
|
308
402
|
}
|
|
309
403
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
404
|
+
handleAsyncCompletion(payload: unknown): void {
|
|
405
|
+
if (!payload || typeof payload !== "object") return;
|
|
406
|
+
const data = payload as { id?: unknown; runId?: unknown; success?: unknown; state?: unknown; summary?: unknown };
|
|
407
|
+
const asyncId = typeof data.runId === "string" ? data.runId : typeof data.id === "string" ? data.id : undefined;
|
|
408
|
+
if (!asyncId) return;
|
|
409
|
+
for (const store of this.stores.values()) {
|
|
410
|
+
let ids: string[];
|
|
411
|
+
try {
|
|
412
|
+
ids = store.ids();
|
|
413
|
+
} catch (error) {
|
|
414
|
+
console.error(`Failed to inspect schedule store '${store.root}' during async completion:`, error);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
for (const id of ids) {
|
|
418
|
+
try {
|
|
419
|
+
const schedule = store.get(id);
|
|
420
|
+
const run = store.history(id).find((item) => item.asyncId === asyncId && item.state === "running");
|
|
421
|
+
if (!run) continue;
|
|
422
|
+
this.finishRun(store, schedule, run, data.success === true, typeof data.summary === "string" ? data.summary : undefined);
|
|
423
|
+
return;
|
|
424
|
+
} catch (error) {
|
|
425
|
+
console.error(`Failed to inspect schedule '${id}' in '${store.root}' during async completion:`, error);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
318
428
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
const
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private create(params: SubagentParamsLike, ctx: ExtensionContext): AgentToolResult<Details> {
|
|
432
|
+
const store = this.requireStore();
|
|
433
|
+
const target = sanitizeTarget(params);
|
|
434
|
+
if (target.error) return textResult(target.error, undefined, undefined, true);
|
|
435
|
+
const at = params.at?.trim();
|
|
436
|
+
const every = params.every?.trim();
|
|
437
|
+
if (Boolean(at) === Boolean(every)) return textResult("schedule.create requires exactly one trigger: at or every.", undefined, undefined, true);
|
|
438
|
+
if (params.overlap !== undefined && params.overlap !== "skip") return textResult("This first recurring slice supports overlap='skip' only.", undefined, undefined, true);
|
|
439
|
+
if (params.catchUp !== undefined && params.catchUp !== "none" && params.catchUp !== "latest") return textResult("catchUp must be 'none' or 'latest'.", undefined, undefined, true);
|
|
440
|
+
if (params.missionId !== undefined || params.mission !== undefined || params.missionUpdate !== undefined || params.missionStatus !== undefined || params.missionScope !== undefined) return textResult("Mission attachment is deferred from this first schedule slice.", undefined, undefined, true);
|
|
441
|
+
if (params.on !== undefined || params.timezone !== undefined || every === "day" || every === "week" || every === "month" || every === "year") return textResult("Calendar schedules are deferred from this first safe slice. Use a fixed interval such as every:'24h' or every:'7d'.", undefined, undefined, true);
|
|
442
|
+
const sessionId = ctx.sessionManager.getSessionId() ?? "unknown";
|
|
443
|
+
if (this.deps.resolveCapabilityCeiling?.(sessionId)) return textResult("Cannot persist a schedule while a capability ceiling is active.", undefined, undefined, true);
|
|
444
|
+
if (store.list().length >= resolveMaxPending(this.deps.config)) return textResult(`Schedule limit reached (${resolveMaxPending(this.deps.config)}).`, undefined, undefined, true);
|
|
445
|
+
const id = validateScheduleId((params.id?.trim() || this.randomId()));
|
|
446
|
+
if (store.list().some((item) => item.id === id)) return textResult(`Schedule '${id}' already exists.`, undefined, undefined, true);
|
|
326
447
|
const now = this.now();
|
|
327
|
-
|
|
448
|
+
let trigger: ScheduleTrigger;
|
|
449
|
+
if (at) {
|
|
450
|
+
const planned = parseScheduledRunTime(at, now);
|
|
451
|
+
trigger = { kind: "once", at, nextRunAt: timestamp(planned) };
|
|
452
|
+
} else {
|
|
453
|
+
const everyMs = parseScheduleInterval(every!);
|
|
454
|
+
trigger = { kind: "interval", every: every!, everyMs, anchorAt: timestamp(now), nextRunAt: timestamp(now + everyMs) };
|
|
455
|
+
}
|
|
456
|
+
const schedule: ScheduleRecord = {
|
|
457
|
+
schemaVersion: 1,
|
|
328
458
|
id,
|
|
329
|
-
name: scheduleName ||
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
459
|
+
name: params.name?.trim() || params.scheduleName?.trim() || targetLabel(target.target!),
|
|
460
|
+
cwd: path.resolve(params.cwd ?? ctx.cwd),
|
|
461
|
+
trigger,
|
|
462
|
+
target: target.target!,
|
|
463
|
+
overlap: "skip",
|
|
464
|
+
catchUp: params.catchUp ?? "latest",
|
|
465
|
+
...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }),
|
|
466
|
+
paused: false,
|
|
467
|
+
createdAt: timestamp(now),
|
|
468
|
+
updatedAt: timestamp(now),
|
|
338
469
|
};
|
|
339
|
-
store.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
return textResult(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (!requestedId) return textResult("action='schedule-status' requires id.", true);
|
|
370
|
-
const job = resolveJobById(this.requireStore().list(), requestedId);
|
|
371
|
-
const lines = [
|
|
372
|
-
`Scheduled run: ${job.id}`,
|
|
373
|
-
`Name: ${job.name}`,
|
|
374
|
-
`State: ${job.state}`,
|
|
375
|
-
`Schedule: ${job.schedule}`,
|
|
376
|
-
`Run at: ${new Date(job.runAt).toISOString()}`,
|
|
377
|
-
`Mode: ${jobMode(job.params)}`,
|
|
378
|
-
`CWD: ${shortenPath(job.cwd)}`,
|
|
379
|
-
`Created: ${new Date(job.createdAt).toISOString()}`,
|
|
380
|
-
`Updated: ${new Date(job.updatedAt).toISOString()}`,
|
|
381
|
-
job.lastRunId ? `Launched async run: ${job.lastRunId}` : undefined,
|
|
382
|
-
job.lastAsyncDir ? `Async dir: ${job.lastAsyncDir}` : undefined,
|
|
383
|
-
job.lastError ? `Error: ${job.lastError}` : undefined,
|
|
384
|
-
job.state === "scheduled" ? `Cancel: subagent({ action: "schedule-cancel", id: "${job.id}" })` : undefined,
|
|
385
|
-
job.lastRunId ? `Async status: subagent({ action: "status", id: "${job.lastRunId}" })` : undefined,
|
|
386
|
-
].filter((line): line is string => Boolean(line));
|
|
387
|
-
return textResult(lines.join("\n"));
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
private cancelJob(params: SubagentParamsLike): AgentToolResult<Details> {
|
|
391
|
-
const requestedId = params.id ?? params.runId;
|
|
392
|
-
if (!requestedId) return textResult("action='schedule-cancel' requires id.", true);
|
|
470
|
+
store.write(schedule);
|
|
471
|
+
store.appendEvent(schedule, "schedule.created");
|
|
472
|
+
this.arm(schedule, store);
|
|
473
|
+
return textResult(`Created schedule ${id}.\nName: ${schedule.name}\nTrigger: ${at ? `at ${at}` : `every ${every}`}\nNext: ${schedule.trigger.nextRunAt}\nTarget: ${targetLabel(schedule.target)}`, [schedule]);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private list(): AgentToolResult<Details> {
|
|
477
|
+
const schedules = this.requireStore().list().sort((a, b) => (a.trigger.nextRunAt ?? "").localeCompare(b.trigger.nextRunAt ?? ""));
|
|
478
|
+
if (!schedules.length) return textResult("No project schedules.", []);
|
|
479
|
+
return textResult([`Project schedules: ${schedules.length}`, ...schedules.map((item) => `- ${item.id} | ${item.paused ? "paused" : item.activeRunId ? "running" : "scheduled"} | ${item.trigger.nextRunAt ?? "no next run"} | ${item.name}`)].join("\n"), schedules);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private show(params: SubagentParamsLike): AgentToolResult<Details> {
|
|
483
|
+
const schedule = this.resolve(params);
|
|
484
|
+
return textResult([`Schedule: ${schedule.id}`, `Name: ${schedule.name}`, `State: ${schedule.paused ? "paused" : schedule.activeRunId ? "running" : "scheduled"}`, `Target: ${targetLabel(schedule.target)}`, `CWD: ${shortenPath(schedule.cwd)}`, `Next: ${schedule.trigger.nextRunAt ?? "none"}`, `Catch up: ${schedule.catchUp}`, schedule.activeRunId ? `Active run: ${schedule.activeRunId}` : undefined].filter(Boolean).join("\n"), [schedule]);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private history(params: SubagentParamsLike): AgentToolResult<Details> {
|
|
488
|
+
const schedule = this.resolve(params);
|
|
489
|
+
const runs = this.requireStore().history(schedule.id);
|
|
490
|
+
return textResult(runs.length ? [`Schedule history: ${schedule.id}`, ...runs.map((run) => `- ${run.id} | ${run.state} | ${run.plannedAt}${run.asyncId ? ` | async ${run.asyncId}` : ""}`)].join("\n") : `No runs recorded for schedule ${schedule.id}.`, [schedule], runs);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private pause(params: SubagentParamsLike, paused: boolean): AgentToolResult<Details> {
|
|
494
|
+
const schedule = this.resolve(params);
|
|
495
|
+
if (schedule.paused === paused) return textResult(`Schedule ${schedule.id} is already ${paused ? "paused" : "active"}.`, [schedule]);
|
|
496
|
+
schedule.paused = paused;
|
|
497
|
+
schedule.updatedAt = timestamp(this.now());
|
|
498
|
+
this.requireStore().write(schedule);
|
|
499
|
+
this.requireStore().appendEvent(schedule, paused ? "schedule.paused" : "schedule.resumed");
|
|
393
500
|
const store = this.requireStore();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
store.mutate((data) => {
|
|
400
|
-
const stored = data.jobs.find((candidate) => candidate.id === job.id);
|
|
401
|
-
if (!stored) return;
|
|
402
|
-
stored.state = "canceled";
|
|
403
|
-
stored.canceledAt = now;
|
|
404
|
-
stored.updatedAt = now;
|
|
405
|
-
});
|
|
406
|
-
return textResult(`Canceled scheduled subagent run ${job.id}.`);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
private rearmScheduledJobs(): void {
|
|
501
|
+
if (paused) this.clearTimer(store, schedule.id); else this.restoreOne(store, schedule);
|
|
502
|
+
return textResult(`${paused ? "Paused" : "Resumed"} schedule ${schedule.id}.`, [schedule]);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private async runManual(params: SubagentParamsLike): Promise<AgentToolResult<Details>> {
|
|
410
506
|
const store = this.requireStore();
|
|
411
|
-
const
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
507
|
+
const schedule = this.resolve(params);
|
|
508
|
+
const run = await this.launch(store, schedule, this.now(), "manual", false);
|
|
509
|
+
return textResult(`Manual schedule run ${run.id}: ${run.state}${run.asyncId ? ` (async ${run.asyncId})` : ""}.`, [store.get(schedule.id)], [run], run.state === "failed_launch");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private async runDue(): Promise<AgentToolResult<Details>> {
|
|
513
|
+
const store = this.requireStore();
|
|
514
|
+
const due = store.list().filter((schedule) => !schedule.paused && nextRunAt(schedule) !== undefined && nextRunAt(schedule)! <= this.now());
|
|
515
|
+
const runs: ScheduleRunRecord[] = [];
|
|
516
|
+
for (const schedule of due) {
|
|
517
|
+
const planned = duePlannedAt(schedule, this.now())!;
|
|
518
|
+
if (!schedule.activeRunId && schedule.catchUp === "none" && planned < this.now()) runs.push(this.recordMissed(store, schedule, planned, "run-due"));
|
|
519
|
+
else runs.push(await this.launch(store, schedule, planned, "run-due", true));
|
|
424
520
|
}
|
|
425
|
-
|
|
426
|
-
|
|
521
|
+
return textResult(runs.length ? `Processed ${runs.length} due schedule(s).` : "No schedules are due.", store.list(), runs);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private remove(params: SubagentParamsLike): AgentToolResult<Details> {
|
|
525
|
+
const schedule = this.resolve(params);
|
|
526
|
+
if (schedule.activeRunId) return textResult(`Schedule ${schedule.id} has active run ${schedule.activeRunId}; stop that run before deleting the schedule.`, [schedule], undefined, true);
|
|
527
|
+
const store = this.requireStore();
|
|
528
|
+
this.clearTimer(store, schedule.id);
|
|
529
|
+
store.appendEvent(schedule, "schedule.deleted");
|
|
530
|
+
store.delete(schedule.id);
|
|
531
|
+
return textResult(`Deleted schedule ${schedule.id}.`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private restore(store: ScheduleStore): void {
|
|
535
|
+
for (const schedule of store.list()) this.restoreOne(store, schedule);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private restoreOne(store: ScheduleStore, schedule: ScheduleRecord): void {
|
|
539
|
+
if (schedule.activeRunId) {
|
|
540
|
+
const run = store.history(schedule.id).find((item) => item.id === schedule.activeRunId);
|
|
541
|
+
const startedAt = run?.startedAt ? Date.parse(run.startedAt) : Number.NaN;
|
|
542
|
+
if (run?.state === "running" && run.asyncDir) {
|
|
543
|
+
try {
|
|
544
|
+
const status = readJson(path.join(run.asyncDir, "status.json"), "async status") as Partial<AsyncStatus>;
|
|
545
|
+
if (["complete", "failed", "stopped", "rejected"].includes(String(status.state))) this.finishRun(store, schedule, run, status.state === "complete", typeof status.error === "string" ? status.error : undefined);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof Error && /ENOENT/.test(error.message))) throw error;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (schedule.activeRunId && (!run || run.state !== "running" || (!run.asyncId && Number.isFinite(startedAt) && startedAt + STALE_LAUNCH_CLAIM_MS <= this.now()))) {
|
|
551
|
+
if (run?.state === "running") {
|
|
552
|
+
run.state = "failed_launch";
|
|
553
|
+
run.completedAt = timestamp(this.now());
|
|
554
|
+
run.error = "Recovered a stale launch claim before an async run was attached.";
|
|
555
|
+
store.writeRun(schedule, run, "schedule.run.failed");
|
|
556
|
+
}
|
|
557
|
+
schedule.activeRunId = undefined;
|
|
558
|
+
schedule.updatedAt = timestamp(this.now());
|
|
559
|
+
store.write(schedule);
|
|
560
|
+
fs.rmSync(path.join(store.directory(schedule.id), "active.lock"), { force: true });
|
|
561
|
+
}
|
|
427
562
|
}
|
|
563
|
+
if (schedule.paused) return;
|
|
564
|
+
const next = nextRunAt(schedule);
|
|
565
|
+
if (next === undefined) return;
|
|
566
|
+
if (!schedule.activeRunId && next < this.now() && schedule.catchUp === "none") this.recordMissed(store, schedule, next, "timer");
|
|
567
|
+
this.arm(schedule, store);
|
|
428
568
|
}
|
|
429
569
|
|
|
430
|
-
private arm(
|
|
431
|
-
this.clearTimer(
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
570
|
+
private arm(schedule: ScheduleRecord, store: ScheduleStore): void {
|
|
571
|
+
this.clearTimer(store, schedule.id);
|
|
572
|
+
if (schedule.paused) return;
|
|
573
|
+
const next = nextRunAt(schedule);
|
|
574
|
+
if (next === undefined) return;
|
|
575
|
+
const timer = this.timersApi.setTimeout(() => void this.fire(store, schedule.id), Math.min(Math.max(0, next - this.now()), MAX_TIMER_DELAY_MS));
|
|
436
576
|
timer.unref?.();
|
|
437
|
-
this.timers.set(
|
|
577
|
+
this.timers.set(this.timerKey(store, schedule.id), timer);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private async fire(store: ScheduleStore, id: string): Promise<void> {
|
|
581
|
+
this.clearTimer(store, id);
|
|
582
|
+
const schedule = store.get(id);
|
|
583
|
+
const planned = duePlannedAt(schedule, this.now());
|
|
584
|
+
if (planned === undefined || schedule.paused) return;
|
|
585
|
+
if (planned > this.now()) return this.arm(schedule, store);
|
|
586
|
+
await this.launch(store, schedule, planned, "timer", true);
|
|
438
587
|
}
|
|
439
588
|
|
|
440
|
-
private async
|
|
441
|
-
this.clearTimer(jobId);
|
|
442
|
-
const store = this.store;
|
|
443
|
-
const ctx = this.ctx;
|
|
444
|
-
if (!store || !ctx) return;
|
|
445
|
-
let job = store.get(jobId);
|
|
446
|
-
if (!job || job.state !== "scheduled") return;
|
|
589
|
+
private async launch(store: ScheduleStore, schedule: ScheduleRecord, planned: number, dueReason: ScheduleRunRecord["dueReason"], advance: boolean): Promise<ScheduleRunRecord> {
|
|
447
590
|
const now = this.now();
|
|
448
|
-
|
|
449
|
-
if (
|
|
450
|
-
|
|
451
|
-
|
|
591
|
+
const run: ScheduleRunRecord = { schemaVersion: 1, id: this.randomId(), scheduleId: schedule.id, plannedAt: timestamp(planned), dueReason, state: "running", startedAt: timestamp(now) };
|
|
592
|
+
if (schedule.activeRunId) {
|
|
593
|
+
run.state = "skipped";
|
|
594
|
+
run.completedAt = timestamp(now);
|
|
595
|
+
if (advance) {
|
|
596
|
+
schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
|
|
597
|
+
schedule.updatedAt = timestamp(now);
|
|
598
|
+
store.write(schedule);
|
|
599
|
+
}
|
|
600
|
+
store.writeRun(schedule, run, "schedule.skipped_overlap");
|
|
601
|
+
this.arm(schedule, store);
|
|
602
|
+
return run;
|
|
452
603
|
}
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
604
|
+
const lockPath = path.join(store.directory(schedule.id, true), "active.lock");
|
|
605
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
606
|
+
let lock: number;
|
|
607
|
+
try {
|
|
608
|
+
lock = fs.openSync(lockPath, "wx", 0o600);
|
|
609
|
+
fs.writeFileSync(lock, run.id, "utf-8");
|
|
610
|
+
fs.closeSync(lock);
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
613
|
+
run.state = "skipped";
|
|
614
|
+
run.completedAt = timestamp(now);
|
|
615
|
+
if (advance) {
|
|
616
|
+
schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
|
|
617
|
+
schedule.updatedAt = timestamp(now);
|
|
618
|
+
store.write(schedule);
|
|
619
|
+
}
|
|
620
|
+
store.writeRun(schedule, run, "schedule.skipped_overlap");
|
|
621
|
+
this.arm(schedule, store);
|
|
622
|
+
return run;
|
|
463
623
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
});
|
|
471
|
-
job = store.get(jobId);
|
|
472
|
-
if (!job || job.state !== "running") return;
|
|
473
|
-
const controller = new AbortController();
|
|
624
|
+
schedule.activeRunId = run.id;
|
|
625
|
+
schedule.lastRunId = run.id;
|
|
626
|
+
if (advance) schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
|
|
627
|
+
schedule.updatedAt = timestamp(now);
|
|
628
|
+
store.write(schedule);
|
|
629
|
+
store.writeRun(schedule, run, "schedule.run.started");
|
|
474
630
|
try {
|
|
475
|
-
const result = await this.deps.launch(
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
stored.lastError = result.content.find((item) => item.type === "text")?.text ?? "Scheduled subagent launch failed.";
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
stored.state = "fired";
|
|
487
|
-
stored.lastRunId = launchRunId;
|
|
488
|
-
stored.lastAsyncDir = result.details?.asyncDir;
|
|
489
|
-
});
|
|
631
|
+
const result = await this.deps.launch(executionParams(schedule), this.requireContext(store), new AbortController().signal);
|
|
632
|
+
const asyncId = result.details?.asyncId ?? result.details?.runId;
|
|
633
|
+
if (result.isError || !asyncId) throw new Error(result.content.find((item) => item.type === "text")?.text ?? "Scheduled launch failed.");
|
|
634
|
+
run.asyncId = asyncId;
|
|
635
|
+
run.asyncDir = result.details?.asyncDir;
|
|
636
|
+
store.writeRun(schedule, run, "schedule.run.attached_async");
|
|
637
|
+
this.arm(schedule, store);
|
|
638
|
+
return run;
|
|
490
639
|
} catch (error) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
});
|
|
640
|
+
run.state = "failed_launch";
|
|
641
|
+
run.completedAt = timestamp(this.now());
|
|
642
|
+
run.error = error instanceof Error ? error.message : String(error);
|
|
643
|
+
schedule.activeRunId = undefined;
|
|
644
|
+
schedule.updatedAt = timestamp(this.now());
|
|
645
|
+
store.write(schedule);
|
|
646
|
+
store.writeRun(schedule, run, "schedule.run.failed");
|
|
647
|
+
fs.rmSync(lockPath, { force: true });
|
|
648
|
+
this.arm(schedule, store);
|
|
649
|
+
return run;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private finishRun(store: ScheduleStore, schedule: ScheduleRecord, run: ScheduleRunRecord, success: boolean, error?: string): void {
|
|
654
|
+
const now = this.now();
|
|
655
|
+
const next = nextRunAt(schedule);
|
|
656
|
+
if (next !== undefined && next <= now) {
|
|
657
|
+
const planned = duePlannedAt(schedule, now)!;
|
|
658
|
+
const skipped: ScheduleRunRecord = {
|
|
659
|
+
schemaVersion: 1,
|
|
660
|
+
id: this.randomId(),
|
|
661
|
+
scheduleId: schedule.id,
|
|
662
|
+
plannedAt: timestamp(planned),
|
|
663
|
+
dueReason: "timer",
|
|
664
|
+
state: "skipped",
|
|
665
|
+
completedAt: timestamp(now),
|
|
666
|
+
};
|
|
667
|
+
schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
|
|
668
|
+
store.writeRun(schedule, skipped, "schedule.skipped_overlap");
|
|
499
669
|
}
|
|
670
|
+
run.state = success ? "completed" : "failed_run";
|
|
671
|
+
run.completedAt = timestamp(now);
|
|
672
|
+
if (!success && error) run.error = error;
|
|
673
|
+
schedule.activeRunId = undefined;
|
|
674
|
+
schedule.updatedAt = timestamp(now);
|
|
675
|
+
store.write(schedule);
|
|
676
|
+
fs.rmSync(path.join(store.directory(schedule.id), "active.lock"), { force: true });
|
|
677
|
+
store.writeRun(schedule, run, success ? "schedule.run.completed" : "schedule.run.failed");
|
|
678
|
+
this.arm(schedule, store);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private recordMissed(store: ScheduleStore, schedule: ScheduleRecord, planned: number, dueReason: ScheduleRunRecord["dueReason"]): ScheduleRunRecord {
|
|
682
|
+
const run: ScheduleRunRecord = {
|
|
683
|
+
schemaVersion: 1,
|
|
684
|
+
id: this.randomId(),
|
|
685
|
+
scheduleId: schedule.id,
|
|
686
|
+
plannedAt: timestamp(planned),
|
|
687
|
+
dueReason,
|
|
688
|
+
state: "missed",
|
|
689
|
+
completedAt: timestamp(this.now()),
|
|
690
|
+
};
|
|
691
|
+
schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, this.now());
|
|
692
|
+
schedule.updatedAt = timestamp(this.now());
|
|
693
|
+
store.write(schedule);
|
|
694
|
+
store.writeRun(schedule, run, "schedule.missed");
|
|
695
|
+
return run;
|
|
500
696
|
}
|
|
501
697
|
|
|
502
|
-
private
|
|
503
|
-
|
|
698
|
+
private selectProject(cwd: string, ctx: ExtensionContext): void {
|
|
699
|
+
const projectCwd = path.resolve(cwd);
|
|
700
|
+
const root = scheduledRunStorePath(projectCwd, undefined, this.deps.storeRoot);
|
|
701
|
+
if (path.resolve(ctx.cwd) === projectCwd) this.contexts.set(root, snapshotContext(ctx, projectCwd));
|
|
702
|
+
else if (!this.contexts.has(root)) throw new Error(`Cannot use project '${projectCwd}' until that project has been opened in this runtime.`);
|
|
703
|
+
let store = this.stores.get(root);
|
|
704
|
+
if (!store) {
|
|
705
|
+
store = new ScheduleStore(root, this.deps.storeRoot === undefined ? projectCwd : undefined);
|
|
706
|
+
this.stores.set(root, store);
|
|
707
|
+
this.restore(store);
|
|
708
|
+
}
|
|
709
|
+
this.store = store;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private resolve(params: SubagentParamsLike): ScheduleRecord {
|
|
713
|
+
const id = params.id?.trim();
|
|
714
|
+
if (!id) throw new Error(`${params.action} requires id.`);
|
|
715
|
+
return this.requireStore().get(id);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
private requireStore(): ScheduleStore {
|
|
719
|
+
if (!this.store) throw new Error("Schedule store is unavailable.");
|
|
504
720
|
return this.store;
|
|
505
721
|
}
|
|
506
722
|
|
|
507
|
-
private
|
|
508
|
-
const
|
|
723
|
+
private requireContext(store: ScheduleStore): ExtensionContext {
|
|
724
|
+
const ctx = this.contexts.get(store.root);
|
|
725
|
+
if (!ctx) throw new Error("Schedule runtime context is unavailable.");
|
|
726
|
+
return ctx;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private timerKey(store: ScheduleStore, id: string): string {
|
|
730
|
+
return `${store.root}\0${id}`;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private clearTimer(store: ScheduleStore, id: string): void {
|
|
734
|
+
const key = this.timerKey(store, id);
|
|
735
|
+
const timer = this.timers.get(key);
|
|
509
736
|
if (!timer) return;
|
|
510
737
|
this.timersApi.clearTimeout(timer);
|
|
511
|
-
this.timers.delete(
|
|
738
|
+
this.timers.delete(key);
|
|
512
739
|
}
|
|
513
740
|
|
|
514
741
|
private stopTimers(): void {
|