@saadnvd1/agent-os 0.3.571 → 0.3.572

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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/app/api/bus/spawn/route.ts +21 -3
  3. package/app/api/load/route.test.ts +32 -0
  4. package/app/api/load/route.ts +20 -0
  5. package/app/api/sessions/[id]/pause/route.ts +97 -0
  6. package/app/api/tasks/[id]/priority/route.ts +29 -0
  7. package/app/api/tasks/route.ts +17 -2
  8. package/components/Chat/ChatPanel.tsx +2 -0
  9. package/components/Chat/PausedBanner.tsx +54 -0
  10. package/components/Draft/DraftChips.tsx +25 -0
  11. package/components/Draft/DraftPanel.tsx +1 -0
  12. package/components/SessionList/QueuedRow.tsx +46 -2
  13. package/components/SessionList/RowSubtitle.tsx +21 -3
  14. package/components/SessionList/SessionRowMenu.tsx +49 -1
  15. package/components/SessionList/useShelfActions.ts +39 -2
  16. package/data/sessions/shelf.ts +50 -0
  17. package/data/tasks/index.ts +2 -0
  18. package/data/tasks/queue.ts +38 -1
  19. package/lib/bus/index.ts +21 -2
  20. package/lib/chat/hold.ts +11 -2
  21. package/lib/db/migrations.ts +56 -1
  22. package/lib/db/types.ts +30 -0
  23. package/lib/done/index.ts +2 -0
  24. package/lib/drafts.ts +3 -0
  25. package/lib/hosts/peer-load.ts +120 -0
  26. package/lib/load/machines.ts +124 -0
  27. package/lib/load/monitor.ts +17 -2
  28. package/lib/load/start-gate.ts +95 -0
  29. package/lib/orchestrator/act.ts +78 -0
  30. package/lib/orchestrator/brakes.ts +2 -0
  31. package/lib/orchestrator/brief.ts +4 -1
  32. package/lib/orchestrator/digest.ts +15 -4
  33. package/lib/orchestrator/serve.ts +18 -0
  34. package/lib/orchestrator/tool-names.ts +3 -0
  35. package/lib/orchestrator/tool-schemas.ts +33 -0
  36. package/lib/orchestrator/tools.ts +7 -1
  37. package/lib/project-config/agentos.schema.json +13 -0
  38. package/lib/project-config/schema.ts +9 -0
  39. package/lib/sidebar/paused.ts +13 -0
  40. package/lib/sidebar/queued.ts +34 -1
  41. package/lib/sidebar/shelves.ts +21 -1
  42. package/lib/sidebar/snooze.ts +26 -0
  43. package/lib/tasks/finish.ts +7 -0
  44. package/lib/tasks/move-carry.ts +1 -0
  45. package/lib/tasks/move-flow.ts +6 -0
  46. package/lib/tasks/pause-hold.ts +70 -0
  47. package/lib/tasks/pause-policy.ts +130 -0
  48. package/lib/tasks/pause.ts +718 -0
  49. package/lib/tasks/placement.ts +219 -0
  50. package/lib/tasks/queue.ts +522 -64
  51. package/lib/tasks/schedule-types.ts +64 -0
  52. package/lib/tasks/schedule.ts +271 -0
  53. package/package.json +1 -1
  54. package/server.ts +2 -1
@@ -100,3 +100,29 @@ export function wakeLabel(
100
100
  return `${date.toLocaleDateString("en-US", { timeZone, weekday: "short" })} ${time}`;
101
101
  return `${date.toLocaleDateString("en-US", { timeZone, month: "short", day: "numeric" })}, ${time}`;
102
102
  }
103
+
104
+ // How long a paused session stays paused: a set time, or until someone
105
+ // resumes it. "Tomorrow" is the same 9am as Snooze's.
106
+ export type PausePreset = "1h" | "2h" | "tomorrow" | "until-resumed";
107
+
108
+ export const PAUSE_PRESETS: { key: PausePreset; label: string }[] = [
109
+ { key: "1h", label: "For 1 hour" },
110
+ { key: "2h", label: "For 2 hours" },
111
+ { key: "tomorrow", label: "Until tomorrow 9 AM" },
112
+ { key: "until-resumed", label: "Until I resume" },
113
+ ];
114
+
115
+ // The instant a pause ends; null: until resumed.
116
+ export function pauseUntil(
117
+ preset: PausePreset,
118
+ now: Date = new Date(),
119
+ timeZone: string = localZone()
120
+ ): Date | null {
121
+ if (preset === "1h") return new Date(now.getTime() + 3_600_000);
122
+ if (preset === "2h") return new Date(now.getTime() + 7_200_000);
123
+ if (preset === "tomorrow") return snoozePreset("tomorrow", now, timeZone);
124
+ return null;
125
+ }
126
+
127
+ export const isPausePreset = (v: unknown): v is PausePreset =>
128
+ PAUSE_PRESETS.some((p) => p.key === v);
@@ -1,4 +1,5 @@
1
1
  import { db, type Session } from "../db";
2
+ import { endPauseOnFinish } from "./pause-hold";
2
3
  import { isMirror, remoteTaskAction } from "./remote";
3
4
  import { deleteWorktree } from "../worktrees";
4
5
  import { settleWorktree, type MergedAs } from "../done/worktree";
@@ -154,6 +155,7 @@ export async function signOffTask(
154
155
  db.prepare(
155
156
  `UPDATE sessions SET task_status = 'merged', pr_status = 'merged' WHERE id = ?`
156
157
  ).run(id);
158
+ endPauseOnFinish(id);
157
159
  db.prepare(
158
160
  `UPDATE stack_items SET status = 'merged', error = NULL WHERE session_id = ?`
159
161
  ).run(id);
@@ -182,6 +184,10 @@ export async function signOffTask(
182
184
  return policy.method;
183
185
  }
184
186
 
187
+ // Between `gh pr merge` and the end of the tidying after it.
188
+ export const isMerging = (id: string): boolean =>
189
+ signingOff.has(id) || afterMerge.has(id);
190
+
185
191
  export function mergeSettled(id: string): Promise<void> {
186
192
  return afterMerge.get(id) ?? Promise.resolve();
187
193
  }
@@ -212,6 +218,7 @@ export async function dropTask(id: string): Promise<void> {
212
218
  db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
213
219
  id
214
220
  );
221
+ endPauseOnFinish(id);
215
222
  db.prepare(
216
223
  `UPDATE stack_items SET status = 'dropped' WHERE session_id = ?`
217
224
  ).run(id);
@@ -31,6 +31,7 @@ export const REKEYED = [
31
31
  export const KEPT = [
32
32
  ["orchestrator_event_log", "subject", "history is never rewritten"],
33
33
  ["stack_items", "session_id", "stacked tasks don't move"],
34
+ ["task_pauses", "session_id", "a paused task can't be moved"],
34
35
  [
35
36
  "task_queue",
36
37
  "after_any",
@@ -42,6 +42,12 @@ export function assertMovable(id: string, toHostId: string): Session {
42
42
  const session = getTaskSession(id);
43
43
  if (!LIVE.has(session.task_status ?? ""))
44
44
  throw new Error(`Task is already ${session.task_status}`);
45
+ if (session.pause_state)
46
+ throw new Error(
47
+ session.pause_state === "paused"
48
+ ? "It's paused: resume it first"
49
+ : `It's ${session.pause_state}: let that finish first`
50
+ );
45
51
  const fromRemote = isRemoteHost(session.host_id);
46
52
  const toRemote = isRemoteHost(toHostId);
47
53
  if (fromRemote === toRemote) {
@@ -0,0 +1,70 @@
1
+ // Whether a session is paused, from the database alone, so the message bus
2
+ // can hold a message for it without loading the pause machinery.
3
+
4
+ import { db, type TaskPause } from "../db";
5
+
6
+ export interface PauseHold {
7
+ pauseId: number;
8
+ until: string | null;
9
+ }
10
+
11
+ export function pauseHoldOf(sessionId: string): PauseHold | null {
12
+ const row = db
13
+ .prepare(
14
+ `SELECT pause_id, paused_until FROM sessions
15
+ WHERE id = ? AND pause_state IN ('pausing', 'paused') AND pause_id IS NOT NULL`
16
+ )
17
+ .get(sessionId) as
18
+ | { pause_id: number; paused_until: string | null }
19
+ | undefined;
20
+ return row ? { pauseId: row.pause_id, until: row.paused_until } : null;
21
+ }
22
+
23
+ // A stored time (UTC, no zone) as this server's clock reads it.
24
+ export function formatUntil(stored: string): string {
25
+ const t = Date.parse(`${stored.replace(" ", "T")}Z`);
26
+ return new Date(t).toLocaleString("en-US", {
27
+ month: "short",
28
+ day: "numeric",
29
+ hour: "numeric",
30
+ minute: "2-digit",
31
+ });
32
+ }
33
+
34
+ // What a sender is told when their message is held.
35
+ export function pausedNote(name: string, hold: PauseHold): string {
36
+ return `${name} is paused${hold.until ? ` until ${formatUntil(hold.until)}` : ""}; it gets this when it resumes`;
37
+ }
38
+
39
+ export function endPause(
40
+ row: Pick<TaskPause, "id" | "session_id">,
41
+ reason: "resumed" | "cancelled" | "finished" | "failed"
42
+ ): void {
43
+ db.transaction(() => {
44
+ db.prepare(
45
+ `UPDATE task_pauses SET state = 'ended', ended_at = datetime('now'), end_reason = ?
46
+ WHERE id = ? AND state != 'ended'`
47
+ ).run(reason, row.id);
48
+ db.prepare(
49
+ `UPDATE sessions SET pause_state = NULL, paused_until = NULL, pause_id = NULL
50
+ WHERE id = ? AND pause_id = ?`
51
+ ).run(row.session_id, row.id);
52
+ })();
53
+ }
54
+
55
+ // A task that merged, was dropped or was marked done is no longer paused.
56
+ // What was held stays as history, read.
57
+ export function endPauseOnFinish(sessionId: string): void {
58
+ const row = db
59
+ .prepare(
60
+ `SELECT id, session_id FROM task_pauses WHERE session_id = ? AND state != 'ended'
61
+ ORDER BY id DESC LIMIT 1`
62
+ )
63
+ .get(sessionId) as { id: number; session_id: string } | undefined;
64
+ if (!row) return;
65
+ endPause(row, "finished");
66
+ db.prepare(
67
+ `UPDATE bus_messages SET read_at = COALESCE(read_at, datetime('now'))
68
+ WHERE held_pause_id = ?`
69
+ ).run(row.id);
70
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The rules of a timed pause, as pure functions: what can't be paused, who
3
+ * may undo whose pause, and the one prompt a session gets when it resumes.
4
+ */
5
+
6
+ import type { PauseBy } from "./schedule-types";
7
+
8
+ export interface PauseFacts {
9
+ // A task (it holds a slot) as opposed to a plain chat or terminal session.
10
+ isTask: boolean;
11
+ isOrchestrator: boolean;
12
+ isQuickChange: boolean;
13
+ // Runs on, or is a mirror of, a linked machine.
14
+ isRemote: boolean;
15
+ taskStatus: string | null;
16
+ pauseState: string | null;
17
+ // Moving, or its stack is landing.
18
+ held: boolean;
19
+ // Between `gh pr merge` and the database knowing it, or tidying after.
20
+ merging: boolean;
21
+ settingUp: boolean;
22
+ // Waiting on a person.
23
+ chatWaiting: boolean;
24
+ terminalWaiting: boolean;
25
+ openAsk: boolean;
26
+ openQuestion: boolean;
27
+ escalated: boolean;
28
+ planAwaiting: boolean;
29
+ // A terminal whose agent can be started again from its saved conversation
30
+ // (Claude with a conversation id). A chat always can.
31
+ resumable: boolean;
32
+ }
33
+
34
+ // Why it can't be paused now, or null.
35
+ export function pauseRefusal(f: PauseFacts): string | null {
36
+ if (f.isOrchestrator)
37
+ return "An orchestrator is paused with its workspace's Pause, not on its own";
38
+ if (f.isQuickChange)
39
+ return "A quick change runs no agent, so there is nothing to pause";
40
+ if (f.isRemote) return "It runs on another machine: pause it from there";
41
+ if (!f.resumable)
42
+ return "Its agent can't be started again from where it stopped, so it can't be paused";
43
+ if (f.isTask && f.taskStatus && f.taskStatus !== "running")
44
+ return `It's ${f.taskStatus}, not running`;
45
+ if (f.pauseState === "paused") return "It's already paused";
46
+ if (f.merging) return "It's merging";
47
+ if (f.held) return "It's moving to another machine or its stack is landing";
48
+ if (f.settingUp) return "It's still setting up; pause it once it has started";
49
+ if (
50
+ f.chatWaiting ||
51
+ f.terminalWaiting ||
52
+ f.openAsk ||
53
+ f.openQuestion ||
54
+ f.escalated ||
55
+ f.planAwaiting
56
+ )
57
+ return "It's waiting on you";
58
+ return null;
59
+ }
60
+
61
+ const RANK: Record<PauseBy, number> = {
62
+ scheduler: 1,
63
+ orchestrator: 2,
64
+ person: 3,
65
+ };
66
+
67
+ // A pause is lifted by its own kind or a higher one: a person above the
68
+ // orchestrator above the scheduler.
69
+ export function mayResume(actor: PauseBy, pausedBy: PauseBy): boolean {
70
+ return RANK[actor] >= RANK[pausedBy];
71
+ }
72
+
73
+ export function resumeRefusal(
74
+ actor: PauseBy,
75
+ pausedBy: PauseBy,
76
+ untilLabel: string | null
77
+ ): string | null {
78
+ if (mayResume(actor, pausedBy)) return null;
79
+ const who = pausedBy === "person" ? "a person" : "the orchestrator";
80
+ return `Paused by ${who}${untilLabel ? ` until ${untilLabel}` : ""}; only ${pausedBy === "person" ? "they" : "the orchestrator or a person"} can resume it`;
81
+ }
82
+
83
+ export interface HeldMessage {
84
+ from: string;
85
+ text: string;
86
+ }
87
+
88
+ const BY_TEXT: Record<PauseBy, string> = {
89
+ person: "by the user",
90
+ orchestrator: "by the orchestrator",
91
+ scheduler: "by the scheduler because the machine was loaded",
92
+ };
93
+
94
+ export function durationText(fromMs: number, untilMs: number | null): string {
95
+ if (untilMs === null) return "until resumed";
96
+ const minutes = Math.max(1, Math.round((untilMs - fromMs) / 60_000));
97
+ if (minutes < 90) return `for ${minutes} minute${minutes === 1 ? "" : "s"}`;
98
+ const hours = Math.round(minutes / 60);
99
+ if (hours < 48) return `for ${hours} hour${hours === 1 ? "" : "s"}`;
100
+ const days = Math.round(hours / 24);
101
+ return `for ${days} days`;
102
+ }
103
+
104
+ // The prompt a resumed session gets, with everything held while it was
105
+ // paused folded in.
106
+ export function continuePrompt(input: {
107
+ pausedAt: string;
108
+ by: PauseBy;
109
+ duration: string;
110
+ interrupted: boolean;
111
+ held: HeldMessage[];
112
+ }): string {
113
+ const lines = [
114
+ `You were paused at ${input.pausedAt} (${BY_TEXT[input.by]}, ${input.duration}) and are resumed now.${
115
+ input.interrupted
116
+ ? " Your last step was interrupted; check whether it finished before redoing it."
117
+ : ""
118
+ }`,
119
+ ];
120
+ if (input.held.length) {
121
+ lines.push(
122
+ `While you were paused, ${input.held.length} message${input.held.length === 1 ? "" : "s"} arrived:`,
123
+ ...input.held.map((m, i) => `${i + 1}. From ${m.from}: ${m.text}`)
124
+ );
125
+ }
126
+ lines.push(
127
+ "Check the worktree, the branch against its PR and CI before you continue, then carry on with the task."
128
+ );
129
+ return lines.join("\n");
130
+ }