@saadnvd1/agent-os 0.3.479 → 0.3.482
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/README.md +17 -1
- package/app/api/schedules/route.ts +1 -0
- package/app/globals.css +7 -0
- package/components/Chat/ChatPanel.tsx +96 -66
- package/components/Chat/Code.tsx +4 -1
- package/components/Chat/Messages.tsx +2 -2
- package/components/Chat/copy-selection.ts +82 -0
- package/components/Chat/stick-to-bottom.test.ts +65 -5
- package/components/Chat/stick-to-bottom.ts +46 -16
- package/components/Chat/timeline-selection.test.ts +146 -0
- package/components/Orchestrator/OrchestratorBar.tsx +12 -0
- package/components/Schedules/ScheduleForm.tsx +94 -12
- package/components/Schedules/SchedulesPanel.tsx +60 -14
- package/components/SessionList/SidebarHeader.tsx +1 -1
- package/components/SessionList/useSchedulesGlance.ts +6 -2
- package/lib/chat/copy-selection.ts +58 -0
- package/lib/chat/drivers/demo.ts +2 -1
- package/lib/chat/test-touch.ts +58 -12
- package/lib/db/migrations.ts +14 -0
- package/lib/orchestrator/brief.ts +3 -0
- package/lib/orchestrator/reminders.ts +91 -0
- package/lib/orchestrator/serve.ts +9 -0
- package/lib/orchestrator/tool-names.ts +3 -0
- package/lib/orchestrator/tool-schemas.ts +17 -0
- package/lib/orchestrator/tools.ts +6 -0
- package/lib/schedules/badge.ts +11 -1
- package/lib/schedules/cron.ts +1 -1
- package/lib/schedules/index.ts +67 -6
- package/lib/schedules/run.ts +23 -6
- package/lib/schedules/scheduler.ts +12 -2
- package/lib/schedules/start.ts +11 -5
- package/lib/schedules/store.ts +133 -16
- package/lib/schedules/when.ts +166 -0
- package/package.json +1 -2
package/lib/chat/test-touch.ts
CHANGED
|
@@ -1,17 +1,63 @@
|
|
|
1
|
-
// For tests
|
|
1
|
+
// For tests that wait on async work or stand in for file watching.
|
|
2
|
+
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { vi } from "vitest";
|
|
2
5
|
|
|
3
6
|
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
4
7
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (seen()) return n;
|
|
13
|
-
await sleep(20);
|
|
14
|
-
}
|
|
8
|
+
// Polls until `check` holds. The ceiling is generous: it only matters when
|
|
9
|
+
// something is really broken, and a loaded machine never reaches it.
|
|
10
|
+
export async function until(check: () => boolean, ceilingMs = 10_000) {
|
|
11
|
+
const end = Date.now() + ceilingMs;
|
|
12
|
+
while (!check()) {
|
|
13
|
+
if (Date.now() > end) throw new Error("condition never held");
|
|
14
|
+
await sleep(10);
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FakeWatch {
|
|
19
|
+
path: string;
|
|
20
|
+
recursive: boolean;
|
|
21
|
+
closed: boolean;
|
|
22
|
+
// Delivers an event as the OS would (null: no file name given).
|
|
23
|
+
fire(name: string | null): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Replaces fs.watch with handles the test fires by hand: the OS's own events
|
|
27
|
+
// arrive when it likes, which under load is never inside a test's wait.
|
|
28
|
+
export function fakeFsWatch() {
|
|
29
|
+
const handles: FakeWatch[] = [];
|
|
30
|
+
const spy = vi.spyOn(fs, "watch").mockImplementation(((
|
|
31
|
+
p: unknown,
|
|
32
|
+
o: unknown,
|
|
33
|
+
listener: (e: string, name: string | null) => void
|
|
34
|
+
) => {
|
|
35
|
+
const h: FakeWatch = {
|
|
36
|
+
path: String(p),
|
|
37
|
+
recursive: !!(o as { recursive?: boolean } | null)?.recursive,
|
|
38
|
+
closed: false,
|
|
39
|
+
fire: (name) => listener("rename", name),
|
|
40
|
+
};
|
|
41
|
+
handles.push(h);
|
|
42
|
+
return {
|
|
43
|
+
on() {},
|
|
44
|
+
close() {
|
|
45
|
+
h.closed = true;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}) as unknown as typeof fs.watch);
|
|
49
|
+
return {
|
|
50
|
+
all: handles,
|
|
51
|
+
open: () => handles.filter((h) => !h.closed),
|
|
52
|
+
// The live handle on `p`.
|
|
53
|
+
at(p: string, recursive?: boolean) {
|
|
54
|
+
return handles.find(
|
|
55
|
+
(h) =>
|
|
56
|
+
!h.closed &&
|
|
57
|
+
h.path === p &&
|
|
58
|
+
(recursive === undefined || h.recursive === recursive)
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
restore: () => spy.mockRestore(),
|
|
62
|
+
};
|
|
17
63
|
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1445,6 +1445,20 @@ const migrations: Migration[] = [
|
|
|
1445
1445
|
);
|
|
1446
1446
|
},
|
|
1447
1447
|
},
|
|
1448
|
+
{
|
|
1449
|
+
id: 63,
|
|
1450
|
+
name: "one_off_schedules",
|
|
1451
|
+
up: (db) =>
|
|
1452
|
+
db.transaction(() => {
|
|
1453
|
+
// A one-off has a run time (epoch ms, UTC) and no cron; done_at is
|
|
1454
|
+
// set once its single run settles. Recurring rows leave both NULL.
|
|
1455
|
+
db.exec(`ALTER TABLE schedules ADD COLUMN run_at INTEGER`);
|
|
1456
|
+
db.exec(`ALTER TABLE schedules ADD COLUMN done_at INTEGER`);
|
|
1457
|
+
db.exec(
|
|
1458
|
+
`CREATE INDEX IF NOT EXISTS idx_schedules_run_at ON schedules(run_at) WHERE run_at IS NOT NULL`
|
|
1459
|
+
);
|
|
1460
|
+
})(),
|
|
1461
|
+
},
|
|
1448
1462
|
];
|
|
1449
1463
|
|
|
1450
1464
|
// `upTo`: stop after this id (tests that start from an older database).
|
|
@@ -107,6 +107,9 @@ Acting:
|
|
|
107
107
|
- \`${TOOL_NAMES.note}\` (text): a line in this workspace's decision log, shown in your chat. Note each decision that matters, with why.
|
|
108
108
|
- \`${TOOL_NAMES.rule_add}\` (text): keep a standing rule for this workspace, in your brief on every start. Saad sees and edits them in Settings → Workspaces.
|
|
109
109
|
- \`${TOOL_NAMES.rule_remove}\` (id): remove a standing rule you kept (Saad's are his to remove).
|
|
110
|
+
- \`${TOOL_NAMES.remind_me}\` (at, prompt, name?): a one-off reminder to yourself ("tomorrow 10am", "in 2h"); it reaches you once as a scheduled prompt, not an approval, even if AgentOS was off. At least 10 minutes ahead. Prefer it to remembering to check back.
|
|
111
|
+
- \`${TOOL_NAMES.reminders}\` (): your upcoming reminders and the last few that ran.
|
|
112
|
+
- \`${TOOL_NAMES.cancel_reminder}\` (id): cancel a reminder you set.
|
|
110
113
|
- \`${TOOL_NAMES.review}\` (target, fresh?): review a task's PR, or an external PR (#N, owner/repo#N or URL), at its head commit, or read the stored verdict.
|
|
111
114
|
- \`${TOOL_NAMES.sign_off}\` (task): merge a task's PR or an external PR through the gates.
|
|
112
115
|
- \`${TOOL_NAMES.arm}\` (task): merge a task's PR by itself the moment every gate passes; one event when it merges or a gate fails.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remind_me, reminders and cancel_reminder: the orchestrator's one-off
|
|
3
|
+
* schedules. A reminder is a schedule that messages the workspace's own
|
|
4
|
+
* orchestrator once, so it arrives as a scheduled prompt and never as an
|
|
5
|
+
* approval. Every call is scoped to the caller's workspace, and only reminders
|
|
6
|
+
* an orchestrator made can be cancelled here: Saad's are his to change.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
archiveSchedule,
|
|
11
|
+
createSchedule,
|
|
12
|
+
getSessionRow,
|
|
13
|
+
listSchedules,
|
|
14
|
+
type Schedule,
|
|
15
|
+
} from "../schedules/store";
|
|
16
|
+
import { reminderName } from "../schedules";
|
|
17
|
+
import { DEFAULT_TIMEZONE, formatRunTime } from "../schedules/cron";
|
|
18
|
+
import { parseWhen, relativeTime } from "../schedules/when";
|
|
19
|
+
import { getOrchestrator } from "./home";
|
|
20
|
+
import { addNote } from "./notes";
|
|
21
|
+
|
|
22
|
+
const SHOW_DONE = 3;
|
|
23
|
+
|
|
24
|
+
const line = (s: Schedule) =>
|
|
25
|
+
`${s.id.slice(0, 8)} ${s.name}: ${formatRunTime(s.run_at ?? 0, s.timezone)} (${relativeTime(s.run_at ?? 0)})`;
|
|
26
|
+
|
|
27
|
+
const madeByOrchestrator = (s: Schedule) =>
|
|
28
|
+
getSessionRow(s.created_by_session_id ?? "")?.role === "orchestrator";
|
|
29
|
+
|
|
30
|
+
export function remindMe(
|
|
31
|
+
w: string,
|
|
32
|
+
at: string,
|
|
33
|
+
prompt: string,
|
|
34
|
+
name?: string
|
|
35
|
+
): string {
|
|
36
|
+
const me = getOrchestrator(w);
|
|
37
|
+
if (!me) throw new Error("This workspace has no orchestrator");
|
|
38
|
+
const runAt = parseWhen(at, DEFAULT_TIMEZONE);
|
|
39
|
+
const schedule = createSchedule({
|
|
40
|
+
workspaceId: w,
|
|
41
|
+
name: name?.trim() || reminderName(w, runAt, DEFAULT_TIMEZONE),
|
|
42
|
+
at: runAt,
|
|
43
|
+
timezone: DEFAULT_TIMEZONE,
|
|
44
|
+
prompt,
|
|
45
|
+
kind: "orchestrator",
|
|
46
|
+
createdBySessionId: me.id,
|
|
47
|
+
});
|
|
48
|
+
addNote(
|
|
49
|
+
w,
|
|
50
|
+
`Reminder set: ${schedule.name}, ${line(schedule).split(": ")[1]}`
|
|
51
|
+
);
|
|
52
|
+
return `Reminder ${schedule.id.slice(0, 8)} set for ${formatRunTime(runAt, DEFAULT_TIMEZONE)} (${relativeTime(runAt)}). It reaches you as a scheduled prompt, not an approval.`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listReminders(w: string): string {
|
|
56
|
+
const all = listSchedules(w).filter((s) => s.run_at != null);
|
|
57
|
+
const upcoming = all
|
|
58
|
+
.filter((s) => s.enabled && !s.done_at)
|
|
59
|
+
.sort((a, b) => (a.run_at ?? 0) - (b.run_at ?? 0));
|
|
60
|
+
const done = all
|
|
61
|
+
.filter((s) => s.done_at)
|
|
62
|
+
.sort((a, b) => (b.done_at ?? 0) - (a.done_at ?? 0))
|
|
63
|
+
.slice(0, SHOW_DONE);
|
|
64
|
+
if (!upcoming.length && !done.length) return "No reminders.";
|
|
65
|
+
return [
|
|
66
|
+
upcoming.length
|
|
67
|
+
? `Upcoming:\n${upcoming.map(line).join("\n")}`
|
|
68
|
+
: "None upcoming.",
|
|
69
|
+
done.length
|
|
70
|
+
? `Recently done:\n${done.map((s) => `${s.id.slice(0, 8)} ${s.name}`).join("\n")}`
|
|
71
|
+
: "",
|
|
72
|
+
]
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.join("\n\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function cancelReminder(w: string, id: string): string {
|
|
78
|
+
const ref = id.trim();
|
|
79
|
+
const matches = listSchedules(w).filter(
|
|
80
|
+
(s) =>
|
|
81
|
+
s.run_at != null &&
|
|
82
|
+
!s.done_at &&
|
|
83
|
+
(s.id === ref || (ref.length >= 6 && s.id.startsWith(ref)))
|
|
84
|
+
);
|
|
85
|
+
// Another workspace's, a done one and one Saad made all read the same.
|
|
86
|
+
if (matches.length !== 1 || !madeByOrchestrator(matches[0]))
|
|
87
|
+
throw new Error(`No reminder "${ref}" in this workspace`);
|
|
88
|
+
archiveSchedule(matches[0].id);
|
|
89
|
+
addNote(w, `Reminder cancelled: ${matches[0].name}`);
|
|
90
|
+
return `Reminder ${matches[0].id.slice(0, 8)} cancelled.`;
|
|
91
|
+
}
|
|
@@ -28,6 +28,7 @@ import { land } from "./signoff";
|
|
|
28
28
|
import { arm, disarm } from "./armed";
|
|
29
29
|
import { quickChange, quickPR } from "./quick";
|
|
30
30
|
import { review } from "./review";
|
|
31
|
+
import { cancelReminder, listReminders, remindMe } from "./reminders";
|
|
31
32
|
import { parseArgs as p, type ToolName } from "./tool-schemas";
|
|
32
33
|
|
|
33
34
|
export { isToolName, TOOLS, type ToolName } from "./tool-schemas";
|
|
@@ -169,6 +170,14 @@ export async function runTool(
|
|
|
169
170
|
addNote(w, `Removed standing rule ${rule.id}: ${rule.text}`);
|
|
170
171
|
return `Rule ${rule.id} removed.`;
|
|
171
172
|
}
|
|
173
|
+
case "remind_me": {
|
|
174
|
+
const a = p(tool, raw);
|
|
175
|
+
return remindMe(w, a.at, a.prompt, a.name);
|
|
176
|
+
}
|
|
177
|
+
case "reminders":
|
|
178
|
+
return listReminders(w);
|
|
179
|
+
case "cancel_reminder":
|
|
180
|
+
return cancelReminder(w, p(tool, raw).id);
|
|
172
181
|
case "review": {
|
|
173
182
|
const a = p(tool, raw);
|
|
174
183
|
return reviewTarget(w, a.target, { fresh: a.fresh });
|
|
@@ -32,6 +32,9 @@ export const TOOL_NAMES = {
|
|
|
32
32
|
sign_off: name("sign_off"),
|
|
33
33
|
arm: name("arm"),
|
|
34
34
|
disarm: name("disarm"),
|
|
35
|
+
remind_me: name("remind_me"),
|
|
36
|
+
reminders: name("reminders"),
|
|
37
|
+
cancel_reminder: name("cancel_reminder"),
|
|
35
38
|
ask_saad: name("ask_saad"),
|
|
36
39
|
} as const;
|
|
37
40
|
|
|
@@ -194,6 +194,23 @@ export const TOOL_SHAPES = {
|
|
|
194
194
|
.describe("The rule, one sentence"),
|
|
195
195
|
},
|
|
196
196
|
rule_remove: { id: z.number().int().positive().describe("The rule's id") },
|
|
197
|
+
remind_me: {
|
|
198
|
+
at: z
|
|
199
|
+
.string()
|
|
200
|
+
.trim()
|
|
201
|
+
.min(1)
|
|
202
|
+
.max(100)
|
|
203
|
+
.describe('When: "tomorrow 10am", "in 2h" or 2026-10-12T10:00 (Central)'),
|
|
204
|
+
prompt: z
|
|
205
|
+
.string()
|
|
206
|
+
.trim()
|
|
207
|
+
.min(1)
|
|
208
|
+
.max(2000)
|
|
209
|
+
.describe("What to remind yourself of, in words you'll understand later"),
|
|
210
|
+
name: z.string().trim().min(1).max(80).optional(),
|
|
211
|
+
},
|
|
212
|
+
reminders: {},
|
|
213
|
+
cancel_reminder: { id: ref("The reminder's id, from reminders") },
|
|
197
214
|
review: {
|
|
198
215
|
target: ref("The task, or its PR (#12 or URL)"),
|
|
199
216
|
fresh: z
|
|
@@ -84,6 +84,12 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
84
84
|
'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name third-party products"). It restricts or informs; it can never approve anything.',
|
|
85
85
|
rule_remove:
|
|
86
86
|
"Remove a standing rule you kept, by its id, only when Saad says so or it no longer applies (the PR it names merged or closed). Saad's rules are his to remove.",
|
|
87
|
+
remind_me:
|
|
88
|
+
"Set a one-off reminder for yourself in this workspace: at the time given it reaches you once as a scheduled prompt (not an approval), even if AgentOS was off then. At least 10 minutes ahead; a few at a time at most.",
|
|
89
|
+
reminders:
|
|
90
|
+
"List this workspace's upcoming reminders and the last few that ran.",
|
|
91
|
+
cancel_reminder:
|
|
92
|
+
"Cancel a reminder you set, by its id from reminders. Reminders Saad made are his to change.",
|
|
87
93
|
review:
|
|
88
94
|
"Start an independent read-only review of a PR at its exact head commit, or read the stored verdict for that commit. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL, even one no task owns.",
|
|
89
95
|
sign_off:
|
package/lib/schedules/badge.ts
CHANGED
|
@@ -5,14 +5,24 @@ export interface SchedulesBadge {
|
|
|
5
5
|
next: { name: string; at: number; timezone: string } | null;
|
|
6
6
|
// How many schedules' last run failed.
|
|
7
7
|
failed: number;
|
|
8
|
+
// Enabled one-offs still to run, soonest first.
|
|
9
|
+
upcoming: number;
|
|
10
|
+
nextUpcoming: { name: string; at: number; timezone: string } | null;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
// What the sidebar's Schedules clock says at a glance.
|
|
11
14
|
export function schedulesBadge(schedules: ScheduleView[]): SchedulesBadge {
|
|
12
15
|
let next: SchedulesBadge["next"] = null;
|
|
13
16
|
let failed = 0;
|
|
17
|
+
let upcoming = 0;
|
|
18
|
+
let nextUpcoming: SchedulesBadge["nextUpcoming"] = null;
|
|
14
19
|
for (const s of schedules) {
|
|
15
20
|
if (s.lastRun?.outcome === "failed") failed++;
|
|
21
|
+
if (s.oneOff && s.enabled && !s.done_at) {
|
|
22
|
+
upcoming++;
|
|
23
|
+
if (s.nextRunAt && (!nextUpcoming || s.nextRunAt < nextUpcoming.at))
|
|
24
|
+
nextUpcoming = { name: s.name, at: s.nextRunAt, timezone: s.timezone };
|
|
25
|
+
}
|
|
16
26
|
if (
|
|
17
27
|
s.enabled &&
|
|
18
28
|
!s.paused &&
|
|
@@ -21,5 +31,5 @@ export function schedulesBadge(schedules: ScheduleView[]): SchedulesBadge {
|
|
|
21
31
|
)
|
|
22
32
|
next = { name: s.name, at: s.nextRunAt, timezone: s.timezone };
|
|
23
33
|
}
|
|
24
|
-
return { next, failed };
|
|
34
|
+
return { next, failed, upcoming, nextUpcoming };
|
|
25
35
|
}
|
package/lib/schedules/cron.ts
CHANGED
|
@@ -100,7 +100,7 @@ export function isTimezone(tz: string): boolean {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
103
|
-
function wall(ms: number, tz: string) {
|
|
103
|
+
export function wall(ms: number, tz: string) {
|
|
104
104
|
let f = formatters.get(tz);
|
|
105
105
|
if (!f) {
|
|
106
106
|
f = new Intl.DateTimeFormat("en-US", {
|
package/lib/schedules/index.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import { getProject } from "../projects";
|
|
9
9
|
import { resolveSession } from "../bus";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_TIMEZONE,
|
|
12
|
+
describeCron,
|
|
13
|
+
everyCron,
|
|
14
|
+
formatRunTime,
|
|
15
|
+
} from "./cron";
|
|
16
|
+
import { parseWhen } from "./when";
|
|
11
17
|
import { runSlot, type RunDeps, type RunResult } from "./run";
|
|
12
18
|
import { nextRunAt } from "./scheduler";
|
|
13
19
|
import { realDeps } from "./start";
|
|
@@ -37,12 +43,28 @@ export interface ScheduleView extends Schedule {
|
|
|
37
43
|
lastRun: ScheduleRun | null;
|
|
38
44
|
// Its workspace's orchestrator is paused, so it skips its slots.
|
|
39
45
|
paused: boolean;
|
|
46
|
+
oneOff: boolean;
|
|
47
|
+
// Who made it: Saad, or the agent session (the orchestrator, or another).
|
|
48
|
+
createdBy: { kind: "saad" | "orchestrator" | "session"; name: string };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function creatorOf(s: Schedule): ScheduleView["createdBy"] {
|
|
52
|
+
const creator = s.created_by_session_id
|
|
53
|
+
? getSessionRow(s.created_by_session_id)
|
|
54
|
+
: null;
|
|
55
|
+
if (!creator) return { kind: "saad", name: "Saad" };
|
|
56
|
+
return creator.role === "orchestrator"
|
|
57
|
+
? { kind: "orchestrator", name: "Orchestrator" }
|
|
58
|
+
: { kind: "session", name: creator.name };
|
|
40
59
|
}
|
|
41
60
|
|
|
42
61
|
export function scheduleView(s: Schedule, now = Date.now()): ScheduleView {
|
|
43
62
|
return {
|
|
44
63
|
...s,
|
|
45
|
-
description:
|
|
64
|
+
description:
|
|
65
|
+
s.run_at != null
|
|
66
|
+
? `Once, ${formatRunTime(s.run_at, s.timezone)}`
|
|
67
|
+
: describeCron(s.cron),
|
|
46
68
|
projectName: s.project_id ? (getProject(s.project_id)?.name ?? null) : null,
|
|
47
69
|
targetName: s.target_session_id
|
|
48
70
|
? (getSessionRow(s.target_session_id)?.name ?? null)
|
|
@@ -50,6 +72,8 @@ export function scheduleView(s: Schedule, now = Date.now()): ScheduleView {
|
|
|
50
72
|
nextRunAt: nextRunAt(s, now),
|
|
51
73
|
lastRun: lastRun(s.id),
|
|
52
74
|
paused: isPaused(s.workspace_id),
|
|
75
|
+
oneOff: s.run_at != null,
|
|
76
|
+
createdBy: creatorOf(s),
|
|
53
77
|
};
|
|
54
78
|
}
|
|
55
79
|
|
|
@@ -65,6 +89,8 @@ export function checkInInput(opts: {
|
|
|
65
89
|
from?: string | null;
|
|
66
90
|
every?: string;
|
|
67
91
|
cron?: string;
|
|
92
|
+
// A one-off instead of a cron: "tomorrow 10am", "in 2h", an ISO time.
|
|
93
|
+
at?: string;
|
|
68
94
|
prompt: string;
|
|
69
95
|
name?: string;
|
|
70
96
|
timezone?: string;
|
|
@@ -79,14 +105,26 @@ export function checkInInput(opts: {
|
|
|
79
105
|
if (sessionWorkspace(creator) !== workspaceId)
|
|
80
106
|
throw new Error(`${session.name} isn't in your workspace`);
|
|
81
107
|
}
|
|
82
|
-
|
|
108
|
+
if (opts.at && (opts.cron || opts.every))
|
|
109
|
+
throw new Error("Give a run time (at) or a repeat (every, cron), not both");
|
|
110
|
+
const tz = opts.timezone?.trim() || DEFAULT_TIMEZONE;
|
|
111
|
+
const runAt = opts.at ? parseWhen(opts.at, tz) : null;
|
|
112
|
+
const cron =
|
|
113
|
+
runAt === null
|
|
114
|
+
? opts.cron?.trim() || everyCron(opts.every ?? "30m")
|
|
115
|
+
: undefined;
|
|
83
116
|
// The workspace's orchestrator is kept as "its orchestrator", not this
|
|
84
117
|
// session's id: each run goes to whichever session is orchestrator then.
|
|
85
118
|
const orchestrator = session.role === "orchestrator";
|
|
86
119
|
return {
|
|
87
120
|
workspaceId,
|
|
88
|
-
name:
|
|
121
|
+
name:
|
|
122
|
+
opts.name?.trim() ||
|
|
123
|
+
(runAt === null
|
|
124
|
+
? `Check-in: ${session.name}`.slice(0, 80)
|
|
125
|
+
: reminderName(workspaceId, runAt, tz)),
|
|
89
126
|
cron,
|
|
127
|
+
at: runAt,
|
|
90
128
|
timezone: opts.timezone,
|
|
91
129
|
prompt: opts.prompt,
|
|
92
130
|
kind: orchestrator ? "orchestrator" : "message",
|
|
@@ -95,12 +133,30 @@ export function checkInInput(opts: {
|
|
|
95
133
|
};
|
|
96
134
|
}
|
|
97
135
|
|
|
136
|
+
// "Reminder Tue, Oct 7, 9:00 AM", with a number after it when that's taken.
|
|
137
|
+
export function reminderName(
|
|
138
|
+
workspaceId: string,
|
|
139
|
+
runAt: number,
|
|
140
|
+
tz = DEFAULT_TIMEZONE
|
|
141
|
+
): string {
|
|
142
|
+
const taken = new Set(
|
|
143
|
+
listSchedules(workspaceId)
|
|
144
|
+
.filter((s) => !s.done_at)
|
|
145
|
+
.map((s) => s.name.toLowerCase())
|
|
146
|
+
);
|
|
147
|
+
const base = `Reminder ${formatRunTime(runAt, tz)}`;
|
|
148
|
+
let name = base;
|
|
149
|
+
for (let n = 2; taken.has(name.toLowerCase()); n++) name = `${base} (${n})`;
|
|
150
|
+
return name;
|
|
151
|
+
}
|
|
152
|
+
|
|
98
153
|
export function scheduleHistory(id: string, limit = 50): ScheduleRun[] {
|
|
99
154
|
return listRuns(id, limit);
|
|
100
155
|
}
|
|
101
156
|
|
|
102
157
|
// Run now: its own slot, whatever the time and while the last run is still
|
|
103
|
-
// working. Pause holds it like any run.
|
|
158
|
+
// working. Pause holds it like any run. A one-off has one slot, so Run now
|
|
159
|
+
// uses up its only run.
|
|
104
160
|
export async function runNow(
|
|
105
161
|
id: string,
|
|
106
162
|
deps: RunDeps = realDeps
|
|
@@ -108,6 +164,11 @@ export async function runNow(
|
|
|
108
164
|
const schedule = getSchedule(id);
|
|
109
165
|
if (!schedule || schedule.archived_at) throw new Error("Schedule not found");
|
|
110
166
|
const result = await runSlot(schedule, Date.now(), "manual", deps);
|
|
111
|
-
if (!result)
|
|
167
|
+
if (!result)
|
|
168
|
+
throw new Error(
|
|
169
|
+
schedule.run_at != null
|
|
170
|
+
? "It already ran: edit it to a new time to run it again"
|
|
171
|
+
: "Couldn't claim a run"
|
|
172
|
+
);
|
|
112
173
|
return result;
|
|
113
174
|
}
|
package/lib/schedules/run.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
attachSession,
|
|
4
4
|
brakeRun,
|
|
5
5
|
claimSlot,
|
|
6
|
+
finishOneOff,
|
|
6
7
|
finishRun,
|
|
7
8
|
lastSettledBefore,
|
|
8
9
|
lastStarted,
|
|
@@ -60,8 +61,15 @@ export async function runSlot(
|
|
|
60
61
|
trigger: RunTrigger,
|
|
61
62
|
deps: RunDeps
|
|
62
63
|
): Promise<RunResult | null> {
|
|
64
|
+
// A one-off has one slot, Run now included: the claim is what stops it
|
|
65
|
+
// running twice.
|
|
66
|
+
const oneOff = schedule.run_at != null;
|
|
67
|
+
if (oneOff) slotAt = schedule.run_at!;
|
|
63
68
|
const slot =
|
|
64
|
-
trigger === "manual"
|
|
69
|
+
trigger === "manual" && !oneOff
|
|
70
|
+
? `manual:${randomUUID()}`
|
|
71
|
+
: slotKey(slotAt);
|
|
72
|
+
if (oneOff && schedule.done_at) return null;
|
|
65
73
|
const runId = claimSlot(schedule.id, slot, slotAt, trigger);
|
|
66
74
|
if (runId === null) return null;
|
|
67
75
|
const caughtUp = trigger === "catch-up" ? "caught up" : null;
|
|
@@ -71,6 +79,7 @@ export async function runSlot(
|
|
|
71
79
|
sessionId: string | null = null
|
|
72
80
|
): RunResult => {
|
|
73
81
|
finishRun(runId, outcome, detail, sessionId);
|
|
82
|
+
if (oneOff) finishOneOff(schedule.id);
|
|
74
83
|
return { runId, outcome, detail, sessionId };
|
|
75
84
|
};
|
|
76
85
|
const joined = (...parts: (string | null)[]) =>
|
|
@@ -90,6 +99,16 @@ export async function runSlot(
|
|
|
90
99
|
return done("failed", why);
|
|
91
100
|
};
|
|
92
101
|
|
|
102
|
+
// A recurring run skips while paused; a one-off has only this run, so it's
|
|
103
|
+
// held like a brake and runs once the workspace resumes.
|
|
104
|
+
const paused = (): RunResult => {
|
|
105
|
+
if (!oneOff)
|
|
106
|
+
return done("skipped", joined(caughtUp, "paused with the orchestrator"));
|
|
107
|
+
const detail = "held: paused with the orchestrator";
|
|
108
|
+
brakeRun(runId, detail);
|
|
109
|
+
return { runId, outcome: "skipped", detail, sessionId: null };
|
|
110
|
+
};
|
|
111
|
+
|
|
93
112
|
// Its session is gone and won't come back: the schedule turns itself off
|
|
94
113
|
// and says so once, instead of failing every slot.
|
|
95
114
|
const gone = targetGone(schedule);
|
|
@@ -100,9 +119,8 @@ export async function runSlot(
|
|
|
100
119
|
}
|
|
101
120
|
const target = targetProblem(schedule);
|
|
102
121
|
if (target) return fail(target);
|
|
103
|
-
if (pausedFor(schedule))
|
|
104
|
-
|
|
105
|
-
if (trigger !== "manual") {
|
|
122
|
+
if (pausedFor(schedule)) return paused();
|
|
123
|
+
if (trigger !== "manual" && !oneOff) {
|
|
106
124
|
const previous = lastStarted(schedule.id, runId);
|
|
107
125
|
if (previous) {
|
|
108
126
|
let busy: boolean;
|
|
@@ -128,8 +146,7 @@ export async function runSlot(
|
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
// Again: Saad may have paused while the overlap check waited.
|
|
131
|
-
if (pausedFor(schedule))
|
|
132
|
-
return done("skipped", joined(caughtUp, "paused with the orchestrator"));
|
|
149
|
+
if (pausedFor(schedule)) return paused();
|
|
133
150
|
try {
|
|
134
151
|
const started = await deps.start(schedule, (id) =>
|
|
135
152
|
attachSession(runId, id)
|
|
@@ -23,14 +23,24 @@ export const LATE_MS = 90 * 1000;
|
|
|
23
23
|
|
|
24
24
|
// The slot a schedule is due to run now, if any.
|
|
25
25
|
export function dueSlot(schedule: Schedule, now: number): number | null {
|
|
26
|
-
if (!schedule.enabled || schedule.archived_at
|
|
26
|
+
if (!schedule.enabled || schedule.archived_at || schedule.done_at)
|
|
27
|
+
return null;
|
|
28
|
+
// A one-off is due from its run time on, whenever AgentOS notices: no
|
|
29
|
+
// armed_at cut-off, so a run missed while it was off still happens.
|
|
30
|
+
if (schedule.run_at != null) {
|
|
31
|
+
if (schedule.run_at > now) return null;
|
|
32
|
+
return slotTaken(schedule.id, slotKey(schedule.run_at))
|
|
33
|
+
? null
|
|
34
|
+
: schedule.run_at;
|
|
35
|
+
}
|
|
27
36
|
const slot = prevRun(schedule.cron, now, schedule.timezone);
|
|
28
37
|
if (slot === null || slot <= schedule.armed_at) return null;
|
|
29
38
|
return slotTaken(schedule.id, slotKey(slot)) ? null : slot;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
export function nextRunAt(schedule: Schedule, now = Date.now()): number | null {
|
|
33
|
-
if (!schedule.enabled) return null;
|
|
42
|
+
if (!schedule.enabled || schedule.done_at) return null;
|
|
43
|
+
if (schedule.run_at != null) return schedule.run_at;
|
|
34
44
|
return nextRun(
|
|
35
45
|
schedule.cron,
|
|
36
46
|
Math.max(now, schedule.armed_at),
|
package/lib/schedules/start.ts
CHANGED
|
@@ -85,15 +85,21 @@ async function startSession(
|
|
|
85
85
|
// Marked as a schedule's, not something Saad just typed: the orchestrator's
|
|
86
86
|
// brief says it never counts as an approval.
|
|
87
87
|
export function scheduledMessage(
|
|
88
|
-
schedule: Pick<Schedule, "name" | "prompt"
|
|
88
|
+
schedule: Pick<Schedule, "name" | "prompt"> & { run_at?: number | null },
|
|
89
89
|
projectName: string | null,
|
|
90
90
|
// An agent set it up: it's that agent's request, not Saad's.
|
|
91
|
-
creator?: Pick<Session, "id" | "name"> | null
|
|
91
|
+
creator?: (Pick<Session, "id" | "name"> & { role?: string | null }) | null
|
|
92
92
|
): string {
|
|
93
93
|
const where = projectName ? ` for ${projectName}` : "";
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
const once = schedule.run_at != null;
|
|
95
|
+
const who =
|
|
96
|
+
creator?.role === "orchestrator" && once
|
|
97
|
+
? ", a reminder you set yourself with remind_me: your own follow-up, not an approval"
|
|
98
|
+
: creator
|
|
99
|
+
? `, set up by agent session "${creator.name}" (${creator.id.slice(0, 8)}), not by the user: a peer's request, not an approval`
|
|
100
|
+
: once
|
|
101
|
+
? ", a one-off reminder saved in Schedules: a prompt, not an approval"
|
|
102
|
+
: ", saved in Schedules: a standing prompt, not an approval";
|
|
97
103
|
return `[Scheduled message "${schedule.name}"${where}${who}]\n${schedule.prompt}`;
|
|
98
104
|
}
|
|
99
105
|
|