@saadnvd1/agent-os 0.3.477 → 0.3.481
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 +26 -1
- package/app/api/schedules/route.ts +1 -0
- package/app/api/sessions/[id]/route.test.ts +66 -0
- package/app/api/sessions/[id]/route.ts +10 -7
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/tmux/kill-all/route.ts +2 -1
- package/components/Orchestrator/OrchestratorBar.tsx +12 -0
- package/components/Schedules/ScheduleForm.tsx +94 -12
- package/components/Schedules/SchedulesPanel.tsx +60 -14
- package/components/SessionList/OrchestratorPinRow.tsx +12 -6
- package/components/SessionList/QuickChangeRow.tsx +58 -0
- package/components/SessionList/SessionRow.tsx +4 -0
- package/components/SessionList/SidebarHeader.tsx +1 -1
- package/components/SessionList/useSchedulesGlance.ts +6 -2
- package/components/Tasks/TaskRow.tsx +2 -17
- package/components/Tasks/task-state-labels.ts +18 -0
- package/lib/away/sweep.ts +2 -0
- package/lib/chat/drivers/demo.ts +2 -1
- package/lib/chat/hold.ts +6 -2
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/test-touch.ts +58 -12
- package/lib/chat/worker/client.ts +2 -3
- package/lib/db/migrations.ts +31 -0
- package/lib/db/types.ts +3 -0
- package/lib/done/index.ts +2 -8
- package/lib/orchestration.ts +4 -5
- package/lib/orchestrator/act.ts +3 -6
- package/lib/orchestrator/brief.ts +8 -3
- package/lib/orchestrator/conditions.ts +2 -1
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +13 -0
- package/lib/orchestrator/git-guard.ts +201 -0
- package/lib/orchestrator/pr-wake.ts +9 -1
- package/lib/orchestrator/quick-store.ts +18 -0
- package/lib/orchestrator/quick.ts +307 -0
- package/lib/orchestrator/rebase.ts +11 -1
- package/lib/orchestrator/reminders.ts +91 -0
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +26 -0
- package/lib/orchestrator/signoff.ts +28 -6
- package/lib/orchestrator/task-state.ts +2 -0
- package/lib/orchestrator/tool-names.ts +12 -2
- package/lib/orchestrator/tool-schemas.ts +43 -0
- package/lib/orchestrator/tools.ts +10 -0
- package/lib/orchestrator/write-guard.ts +80 -3
- 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/lib/sessions/end.ts +15 -0
- package/lib/sidebar/shelves.ts +19 -1
- package/lib/tasks/finish.ts +2 -8
- package/lib/tasks/index.ts +18 -6
- package/lib/tasks/move-guard.ts +2 -0
- package/lib/tasks/move.ts +2 -6
- package/lib/tasks/queue.ts +3 -2
- package/lib/tasks/start.ts +2 -4
- package/lib/tmux/end.ts +33 -0
- package/lib/tmux/orphans.ts +146 -0
- package/package.json +1 -1
- package/server.ts +9 -0
|
@@ -26,6 +26,9 @@ import { readSession } from "./read";
|
|
|
26
26
|
import { reviewTarget, signOffTarget } from "./external-gates";
|
|
27
27
|
import { land } from "./signoff";
|
|
28
28
|
import { arm, disarm } from "./armed";
|
|
29
|
+
import { quickChange, quickPR } from "./quick";
|
|
30
|
+
import { review } from "./review";
|
|
31
|
+
import { cancelReminder, listReminders, remindMe } from "./reminders";
|
|
29
32
|
import { parseArgs as p, type ToolName } from "./tool-schemas";
|
|
30
33
|
|
|
31
34
|
export { isToolName, TOOLS, type ToolName } from "./tool-schemas";
|
|
@@ -37,6 +40,8 @@ const ACTING = new Set<ToolName>([
|
|
|
37
40
|
"start_task",
|
|
38
41
|
"set_task_model",
|
|
39
42
|
"start_session",
|
|
43
|
+
"quick_change",
|
|
44
|
+
"quick_pr",
|
|
40
45
|
"stack",
|
|
41
46
|
"land",
|
|
42
47
|
"drop",
|
|
@@ -119,6 +124,19 @@ export async function runTool(
|
|
|
119
124
|
view: a.view,
|
|
120
125
|
});
|
|
121
126
|
}
|
|
127
|
+
case "quick_change": {
|
|
128
|
+
const a = p(tool, raw);
|
|
129
|
+
return quickChange(w, a.project, a.name);
|
|
130
|
+
}
|
|
131
|
+
case "quick_pr": {
|
|
132
|
+
const a = p(tool, raw);
|
|
133
|
+
const opened = await quickPR(w, a.change, a.title, a.body);
|
|
134
|
+
// Its code-review gate is this review: it starts at once.
|
|
135
|
+
const reviewing = await review(w, opened.id).catch(
|
|
136
|
+
(e: Error) => `Its review couldn't start (${e.message}): call review.`
|
|
137
|
+
);
|
|
138
|
+
return `${opened.text}\n${reviewing}`;
|
|
139
|
+
}
|
|
122
140
|
case "stack": {
|
|
123
141
|
const a = p(tool, raw);
|
|
124
142
|
return stack(w, a.target, a.plan_only);
|
|
@@ -152,6 +170,14 @@ export async function runTool(
|
|
|
152
170
|
addNote(w, `Removed standing rule ${rule.id}: ${rule.text}`);
|
|
153
171
|
return `Rule ${rule.id} removed.`;
|
|
154
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);
|
|
155
181
|
case "review": {
|
|
156
182
|
const a = p(tool, raw);
|
|
157
183
|
return reviewTarget(w, a.target, { fresh: a.fresh });
|
|
@@ -15,6 +15,7 @@ import { getStack, landInBackground } from "../stacks";
|
|
|
15
15
|
import { LAND_DEFAULTS } from "../stacks/land";
|
|
16
16
|
import { db, stackQueries, type Session } from "../db";
|
|
17
17
|
import { getCheck } from "./checks";
|
|
18
|
+
import { isQuick, quickCodeReview } from "./quick";
|
|
18
19
|
import {
|
|
19
20
|
addedLines,
|
|
20
21
|
changedFiles,
|
|
@@ -82,6 +83,22 @@ export async function judge(
|
|
|
82
83
|
};
|
|
83
84
|
const sha = pr.head;
|
|
84
85
|
const repo = repoOf(task);
|
|
86
|
+
// A PR quick_pr opened (by the row AgentOS wrote, never the body): its
|
|
87
|
+
// code review is the independent review of this head, and until that
|
|
88
|
+
// passes it waits, before any gate is judged or counted.
|
|
89
|
+
const quick = isQuick(task)
|
|
90
|
+
? quickCodeReview(
|
|
91
|
+
sha,
|
|
92
|
+
getCheck(task.id, sha, "review"),
|
|
93
|
+
restackedOf(task)?.to === sha
|
|
94
|
+
)
|
|
95
|
+
: null;
|
|
96
|
+
if (quick?.wait)
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
wait: true,
|
|
100
|
+
text: `${task.name} (PR #${pr.number} at ${short(sha)}) can't merge yet: ${quick.wait}`,
|
|
101
|
+
};
|
|
85
102
|
return gateVerdict(
|
|
86
103
|
workspaceId,
|
|
87
104
|
{ id: task.id, name: task.name, repo, refs: task, pr, sha },
|
|
@@ -100,12 +117,17 @@ export async function judge(
|
|
|
100
117
|
fromCard: !!task.lh_card_id,
|
|
101
118
|
scope: getCheck(task.id, sha, "scope"),
|
|
102
119
|
stackRefusal: landing ? null : signOffRefusal(task.id),
|
|
103
|
-
codeReview:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
120
|
+
codeReview: (() => {
|
|
121
|
+
const gate = codeReviewGate({
|
|
122
|
+
head: sha,
|
|
123
|
+
attribution: pr.attribution,
|
|
124
|
+
review: (at) => getCheck(task.id, at, "review"),
|
|
125
|
+
restacked: restackedOf(task),
|
|
126
|
+
});
|
|
127
|
+
// A quick change keeps the body checks (attribution, unreadable);
|
|
128
|
+
// its wait on the review was judged above.
|
|
129
|
+
return quick ? { refusal: gate.refusal, wait: null } : gate;
|
|
130
|
+
})(),
|
|
109
131
|
}),
|
|
110
132
|
opts
|
|
111
133
|
);
|
|
@@ -17,6 +17,8 @@ import { untrusted } from "./untrusted";
|
|
|
17
17
|
export async function waitingState(
|
|
18
18
|
task: Session
|
|
19
19
|
): Promise<{ blocked: string | null; waitingOn: string | null }> {
|
|
20
|
+
// A quick change has no agent to block or ask: the orchestrator makes it.
|
|
21
|
+
if (task.quick_of) return { blocked: null, waitingOn: null };
|
|
20
22
|
// A linked machine's chat lives in that machine's store, not this one's:
|
|
21
23
|
// its own reading of the task is asked for, fresh.
|
|
22
24
|
const link = hostLink(task.host_id);
|
|
@@ -16,6 +16,8 @@ export const TOOL_NAMES = {
|
|
|
16
16
|
start_task: name("start_task"),
|
|
17
17
|
set_task_model: name("set_task_model"),
|
|
18
18
|
start_session: name("start_session"),
|
|
19
|
+
quick_change: name("quick_change"),
|
|
20
|
+
quick_pr: name("quick_pr"),
|
|
19
21
|
stack: name("stack"),
|
|
20
22
|
stack_status: name("stack_status"),
|
|
21
23
|
land: name("land"),
|
|
@@ -30,6 +32,9 @@ export const TOOL_NAMES = {
|
|
|
30
32
|
sign_off: name("sign_off"),
|
|
31
33
|
arm: name("arm"),
|
|
32
34
|
disarm: name("disarm"),
|
|
35
|
+
remind_me: name("remind_me"),
|
|
36
|
+
reminders: name("reminders"),
|
|
37
|
+
cancel_reminder: name("cancel_reminder"),
|
|
33
38
|
ask_saad: name("ask_saad"),
|
|
34
39
|
} as const;
|
|
35
40
|
|
|
@@ -39,8 +44,11 @@ export const TOOL_NAMES = {
|
|
|
39
44
|
// braked; the shell keeps only `aos` commands that read, and notify, which
|
|
40
45
|
// reaches only Saad, so `aos send`, `task`, `spawn` and `stack` can't go
|
|
41
46
|
// around them. It writes files, but never in a project repository, a task
|
|
42
|
-
// worktree, AgentOS's own state or a secret file
|
|
43
|
-
//
|
|
47
|
+
// worktree, AgentOS's own state or a secret file, except inside its own
|
|
48
|
+
// quick changes' worktrees (quick.ts), where it may also run git that
|
|
49
|
+
// looks, stages or undoes, never commits with hooks or pushes: its worker
|
|
50
|
+
// checks each write and each shell command first (write-guard.ts,
|
|
51
|
+
// git-guard.ts).
|
|
44
52
|
export const ORCHESTRATOR_PERMISSIONS: {
|
|
45
53
|
permissionMode: PermissionMode;
|
|
46
54
|
allowedTools: string[];
|
|
@@ -67,6 +75,8 @@ export const ORCHESTRATOR_PERMISSIONS: {
|
|
|
67
75
|
"Bash(aos notify:*)",
|
|
68
76
|
"Bash(aos docs:*)",
|
|
69
77
|
"Bash(aos doc:*)",
|
|
78
|
+
// Only `git -C <its own quick worktree>`, refused anywhere else.
|
|
79
|
+
"Bash(git:*)",
|
|
70
80
|
],
|
|
71
81
|
disallowedTools: [],
|
|
72
82
|
};
|
|
@@ -135,6 +135,32 @@ export const TOOL_SHAPES = {
|
|
|
135
135
|
.describe("A short name, 2-6 words (default: one made from the prompt)"),
|
|
136
136
|
view,
|
|
137
137
|
},
|
|
138
|
+
quick_change: {
|
|
139
|
+
project,
|
|
140
|
+
name: z
|
|
141
|
+
.string()
|
|
142
|
+
.trim()
|
|
143
|
+
.min(1)
|
|
144
|
+
.max(80)
|
|
145
|
+
.describe("A short name for the change, 2-6 words"),
|
|
146
|
+
},
|
|
147
|
+
quick_pr: {
|
|
148
|
+
change: ref("Your quick change, by name or id"),
|
|
149
|
+
title: z
|
|
150
|
+
.string()
|
|
151
|
+
.trim()
|
|
152
|
+
.min(1)
|
|
153
|
+
.max(200)
|
|
154
|
+
.describe("The PR's title, also its commit message"),
|
|
155
|
+
body: z
|
|
156
|
+
.string()
|
|
157
|
+
.trim()
|
|
158
|
+
.min(1)
|
|
159
|
+
.max(20000)
|
|
160
|
+
.describe(
|
|
161
|
+
"What changed and why; the review judges the change against it"
|
|
162
|
+
),
|
|
163
|
+
},
|
|
138
164
|
stack: {
|
|
139
165
|
target: ref("Board or project name"),
|
|
140
166
|
plan_only: z.boolean().optional(),
|
|
@@ -168,6 +194,23 @@ export const TOOL_SHAPES = {
|
|
|
168
194
|
.describe("The rule, one sentence"),
|
|
169
195
|
},
|
|
170
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") },
|
|
171
214
|
review: {
|
|
172
215
|
target: ref("The task, or its PR (#12 or URL)"),
|
|
173
216
|
fresh: z
|
|
@@ -66,6 +66,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
66
66
|
'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
|
|
67
67
|
start_session:
|
|
68
68
|
"Start an interactive agent session in a project with a prompt. It runs as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.",
|
|
69
|
+
quick_change:
|
|
70
|
+
"Make a small, low-risk edit yourself (a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. You may write there (Write, Edit) and run git there as `git -C <path> ...` (status, diff, add, rm, mv, restore, reset, log, show); nowhere else in a project. Anything needing real work, tests or an investigation is a task (start_task).",
|
|
71
|
+
quick_pr:
|
|
72
|
+
"Commit everything in a quick change's worktree, push its branch (never the default branch) and open its PR, or push to the PR it already has. The PR goes through the same gates as a task's; with no task agent to run /do-code-review, its code-review gate is the independent review of its exact head, which starts now.",
|
|
69
73
|
stack:
|
|
70
74
|
"Run a LumifyHub board's open cards as stacked tasks (each start braked), or with plan_only just show the plan.",
|
|
71
75
|
stack_status: "One stack's items, PRs and progress.",
|
|
@@ -80,6 +84,12 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
80
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.',
|
|
81
85
|
rule_remove:
|
|
82
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.",
|
|
83
93
|
review:
|
|
84
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.",
|
|
85
95
|
sign_off:
|
|
@@ -21,6 +21,8 @@ import { isSecretPath } from "../secret-paths";
|
|
|
21
21
|
import { WORKTREES_DIR } from "../worktrees";
|
|
22
22
|
import { orchestratorDir } from "./home";
|
|
23
23
|
import { LIMITS_FILE } from "./usage";
|
|
24
|
+
import { GIT_LAYOUT_FILE, readCommand } from "./git-guard";
|
|
25
|
+
import { quickWorktrees } from "./quick-store";
|
|
24
26
|
|
|
25
27
|
// The tools that write a file, and the argument naming it.
|
|
26
28
|
export const WRITE_TOOLS: Record<string, string> = {
|
|
@@ -43,6 +45,9 @@ export interface WriteBounds {
|
|
|
43
45
|
home: string;
|
|
44
46
|
// Folders whose files run (~/Library, PATH, the usage brake's input...).
|
|
45
47
|
runs: string[];
|
|
48
|
+
// Its own quick changes' worktrees (quick.ts): the one place in a
|
|
49
|
+
// project it may write, everything else in `repos` still refused.
|
|
50
|
+
quick?: string[];
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
// Wherever they are, files that configure what an agent, git or a
|
|
@@ -129,6 +134,7 @@ export async function writeRefusal(
|
|
|
129
134
|
let ownDir: string[];
|
|
130
135
|
let home: string[];
|
|
131
136
|
let runs: string[];
|
|
137
|
+
let quick: string[][];
|
|
132
138
|
try {
|
|
133
139
|
// Where it lands as written, and with `..` taken by name first, as a
|
|
134
140
|
// tool that normalises the path before opening it would; and where it
|
|
@@ -151,16 +157,27 @@ export async function writeRefusal(
|
|
|
151
157
|
state = await both(bounds.state);
|
|
152
158
|
ownDir = await both(bounds.ownDir);
|
|
153
159
|
home = await both(bounds.home);
|
|
160
|
+
quick = await Promise.all((bounds.quick ?? []).map(both));
|
|
154
161
|
} catch (e) {
|
|
155
162
|
return `Couldn't resolve ${filePath}: ${(e as Error).message}.`;
|
|
156
163
|
}
|
|
157
164
|
for (const target of targets) {
|
|
158
|
-
|
|
165
|
+
// Inside one of its quick worktrees, the roots that hold that worktree
|
|
166
|
+
// (the worktrees folder, a project above it) don't count; any other
|
|
167
|
+
// root, one nested inside it included, still refuses.
|
|
168
|
+
const ownQuick = quick.find((q) => q.some((d) => within(target, d)));
|
|
169
|
+
const holdsQuick = (r: string) =>
|
|
170
|
+
!!ownQuick && ownQuick.some((d) => within(d, r));
|
|
171
|
+
const repo = repos.find((r) => within(target, r) && !holdsQuick(r));
|
|
159
172
|
if (repo)
|
|
160
|
-
return `${filePath} is inside ${repo}, a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), so its PR goes through the merge gates.`;
|
|
173
|
+
return `${filePath} is inside ${repo}, a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), or for a small edit, in a quick change's worktree (quick_change), so its PR goes through the merge gates.`;
|
|
174
|
+
if (ownQuick && GIT_LAYOUT_FILE.test(target))
|
|
175
|
+
return `${filePath} is part of a git repository's layout (HEAD, .git/, .gitattributes); git config can run programs, so orchestrators never write those.`;
|
|
161
176
|
if (AGENTOS_TEMP.test(target))
|
|
162
177
|
return `${filePath} is in a checkout AgentOS is reviewing or rebasing; orchestrators never write there.`;
|
|
163
|
-
|
|
178
|
+
// Its folder, or a quick worktree (which sits in ~/.agent-os/worktrees):
|
|
179
|
+
// AgentOS's state and the home's dot-folders hold it, but it's its own.
|
|
180
|
+
const own = ownDir.some((d) => within(target, d)) || !!ownQuick;
|
|
164
181
|
if (state.some((d) => within(target, d)) && !own)
|
|
165
182
|
return `${filePath} is in AgentOS's own state (${bounds.state}); you may write only in your folder, ${bounds.ownDir}.`;
|
|
166
183
|
if (isSecretPath(target) || isSecretPath(filePath))
|
|
@@ -219,6 +236,7 @@ export async function orchestratorWriteBounds(
|
|
|
219
236
|
...(process.env.PATH ?? "").split(":").filter((d) => d.startsWith("/")),
|
|
220
237
|
],
|
|
221
238
|
ownDir: orchestratorDir(session.workspace_id ?? ""),
|
|
239
|
+
quick: quickWorktrees(session.id),
|
|
222
240
|
state: path.join(os.homedir(), ".agent-os"),
|
|
223
241
|
repos: [...repos, WORKTREES_DIR, process.cwd(), path.dirname(dbPath)],
|
|
224
242
|
};
|
|
@@ -287,9 +305,68 @@ export async function orchestratorToolRefusal(
|
|
|
287
305
|
tool: string,
|
|
288
306
|
input: Record<string, unknown>
|
|
289
307
|
): Promise<string | null> {
|
|
308
|
+
if (tool === "Bash") return commandRefusal(session, input.command);
|
|
290
309
|
const arg = WRITE_TOOLS[tool];
|
|
291
310
|
if (!arg) return null;
|
|
292
311
|
const filePath = input[arg];
|
|
293
312
|
if (typeof filePath !== "string") return "No file path to check.";
|
|
294
313
|
return writeRefusal(filePath, await orchestratorWriteBounds(session));
|
|
295
314
|
}
|
|
315
|
+
|
|
316
|
+
// A shell command: one plain command, and git only in its own quick
|
|
317
|
+
// worktrees (git-guard.ts), the folder it names resolved like a write's.
|
|
318
|
+
async function commandRefusal(
|
|
319
|
+
session: Session,
|
|
320
|
+
command: unknown
|
|
321
|
+
): Promise<string | null> {
|
|
322
|
+
const read = readCommand(command);
|
|
323
|
+
if (read.refusal !== null || read.dir === null) return read.refusal;
|
|
324
|
+
const quick = quickWorktrees(session.id);
|
|
325
|
+
const dir = read.dir;
|
|
326
|
+
if (!dir.startsWith("/") || !quick.length)
|
|
327
|
+
return `Refused: git runs only in one of your quick changes' worktrees, named by its full path (${quick.length ? quick.join(", ") : "you have none open; start one with quick_change"}).`;
|
|
328
|
+
return (await isQuickRoot(dir, quick))
|
|
329
|
+
? null
|
|
330
|
+
: `Refused: ${read.dir} isn't the root of one of your quick changes' worktrees (${quick.join(", ")}). Git runs at the root only, never in a subfolder, a project checkout or another session's worktree.`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Whether a folder is inside one of these worktrees by every way the
|
|
334
|
+
// system could read it: as named, with `..` taken by name, and with links
|
|
335
|
+
// followed.
|
|
336
|
+
export async function insideQuick(
|
|
337
|
+
dir: string,
|
|
338
|
+
quick: string[]
|
|
339
|
+
): Promise<boolean> {
|
|
340
|
+
try {
|
|
341
|
+
const forms = [
|
|
342
|
+
path.resolve(dir),
|
|
343
|
+
await physicalPath(dir),
|
|
344
|
+
await physicalPath(path.resolve(dir)),
|
|
345
|
+
];
|
|
346
|
+
const roots = (
|
|
347
|
+
await Promise.all(
|
|
348
|
+
quick.map(async (q) => [path.resolve(q), await physicalPath(q)])
|
|
349
|
+
)
|
|
350
|
+
).flat();
|
|
351
|
+
return forms.every((f) => roots.some((r) => within(f, r)));
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Whether a folder is exactly the root of one of these worktrees (as named,
|
|
358
|
+
// and with links followed), where git finds the real git dir.
|
|
359
|
+
async function isQuickRoot(dir: string, quick: string[]): Promise<boolean> {
|
|
360
|
+
if (!(await insideQuick(dir, quick))) return false;
|
|
361
|
+
try {
|
|
362
|
+
const forms = [path.resolve(dir), await physicalPath(dir)];
|
|
363
|
+
const roots = (
|
|
364
|
+
await Promise.all(
|
|
365
|
+
quick.map(async (q) => [path.resolve(q), await physicalPath(q)])
|
|
366
|
+
)
|
|
367
|
+
).flat();
|
|
368
|
+
return forms.every((f) => roots.includes(f));
|
|
369
|
+
} catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
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),
|