@saadnvd1/agent-os 0.3.592 → 0.3.594

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 (39) hide show
  1. package/app/api/bus/resurrect/route.test.ts +47 -0
  2. package/app/api/bus/resurrect/route.ts +36 -0
  3. package/app/api/sessions/[id]/pause/route.ts +5 -2
  4. package/app/api/sessions/[id]/resurrect/route.test.ts +32 -0
  5. package/app/api/sessions/[id]/resurrect/route.ts +24 -0
  6. package/app/api/sessions/ended/route.ts +19 -0
  7. package/components/Archived/ArchivedRow.tsx +11 -7
  8. package/components/Chat/PausedBanner.tsx +1 -1
  9. package/components/SessionList/RowSubtitle.tsx +1 -0
  10. package/components/SessionList/SessionRowMenu.tsx +12 -1
  11. package/components/SessionList/useDoneAction.ts +14 -0
  12. package/components/SessionList/useResurrectAction.test.ts +96 -0
  13. package/components/Tasks/TaskRow.tsx +18 -0
  14. package/data/done/index.ts +17 -0
  15. package/lib/db/migrations.ts +11 -0
  16. package/lib/db/types.ts +2 -0
  17. package/lib/done/worktree.ts +18 -2
  18. package/lib/orchestrator/act.ts +44 -5
  19. package/lib/orchestrator/brief.ts +2 -1
  20. package/lib/orchestrator/digest.ts +3 -2
  21. package/lib/orchestrator/serve.ts +6 -0
  22. package/lib/orchestrator/shell-guard.ts +2 -1
  23. package/lib/orchestrator/tool-names.ts +1 -0
  24. package/lib/orchestrator/tool-schemas.ts +6 -0
  25. package/lib/orchestrator/tools.ts +3 -1
  26. package/lib/resurrect/find.ts +111 -0
  27. package/lib/resurrect/index.ts +422 -0
  28. package/lib/resurrect/plan.ts +61 -0
  29. package/lib/resurrect/snapshot.ts +229 -0
  30. package/lib/security/demo.ts +1 -0
  31. package/lib/sidebar/paused.ts +8 -1
  32. package/lib/sidebar/row-actions.ts +7 -3
  33. package/lib/sidebar/shelves.ts +7 -1
  34. package/lib/tasks/auto-hold.ts +17 -0
  35. package/lib/tasks/finish.ts +9 -0
  36. package/lib/tasks/pause-hold.ts +1 -1
  37. package/lib/tasks/pause.ts +76 -8
  38. package/lib/tasks/queue.ts +39 -12
  39. package/package.json +1 -1
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A plan-only task's plan file lives in its worktree and in git nowhere, so a
3
+ * worktree that's gone takes it along. The conversation still has it: the
4
+ * Write that made it and the Edits after. Replays them.
5
+ */
6
+
7
+ import { PLAN_FILE } from "../models/plan-step";
8
+
9
+ interface ToolUse {
10
+ name?: string;
11
+ input?: {
12
+ file_path?: unknown;
13
+ content?: unknown;
14
+ old_string?: unknown;
15
+ new_string?: unknown;
16
+ replace_all?: unknown;
17
+ };
18
+ }
19
+
20
+ function* toolUses(jsonl: string): Generator<ToolUse> {
21
+ for (const line of jsonl.split("\n")) {
22
+ if (!line.includes("tool_use")) continue;
23
+ let entry: { message?: { content?: unknown } };
24
+ try {
25
+ entry = JSON.parse(line);
26
+ } catch {
27
+ continue;
28
+ }
29
+ const content = entry.message?.content;
30
+ if (!Array.isArray(content)) continue;
31
+ for (const part of content)
32
+ if (part?.type === "tool_use") yield part as ToolUse;
33
+ }
34
+ }
35
+
36
+ export function planFromTranscript(jsonl: string): string | null {
37
+ let text: string | null = null;
38
+ for (const use of toolUses(jsonl)) {
39
+ const file = use.input?.file_path;
40
+ if (typeof file !== "string" || !file.endsWith(PLAN_FILE)) continue;
41
+ if (use.name === "Write" && typeof use.input?.content === "string") {
42
+ text = use.input.content;
43
+ } else if (
44
+ use.name === "Edit" &&
45
+ text !== null &&
46
+ typeof use.input?.old_string === "string" &&
47
+ typeof use.input.new_string === "string" &&
48
+ text.includes(use.input.old_string)
49
+ ) {
50
+ const { old_string: from, new_string: to } = use.input as {
51
+ old_string: string;
52
+ new_string: string;
53
+ };
54
+ text =
55
+ use.input.replace_all === true
56
+ ? text.split(from).join(to)
57
+ : text.replace(from, () => to);
58
+ }
59
+ }
60
+ return text;
61
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * A session's worktree, saved as one hidden commit before an end removes it:
3
+ * refs/agentos/ended/<session id>, parented on the branch tip, holding the
4
+ * tracked changes, the untracked files and the (git-ignored) plan file. It is
5
+ * built in a temporary index, so the worktree, its index and its branch are
6
+ * never touched, and takes about a tenth of a second.
7
+ *
8
+ * Restoring puts the worktree back on its branch with those files unstaged.
9
+ * Without a snapshot (an end from before this existed, or one that failed) it
10
+ * falls back to the branch, wherever it still exists.
11
+ */
12
+
13
+ import fs from "fs";
14
+ import os from "os";
15
+ import path from "path";
16
+ import { git as runGit, gitOk } from "../git-exec";
17
+ import { PLAN_FILE, preparePlanDir } from "../models/plan-step";
18
+ import { addWorktree } from "../worktree-add";
19
+ import { installWorktreeHooks } from "../worktree-hooks";
20
+
21
+ const SNAPSHOT_TIMEOUT_MS = 5000;
22
+ // An untracked file bigger than this is left out: a build artefact, not work.
23
+ const MAX_FILE_BYTES = 1024 * 1024;
24
+ const MAX_FILES = 2000;
25
+
26
+ const IDENTITY = {
27
+ GIT_AUTHOR_NAME: "AgentOS",
28
+ GIT_AUTHOR_EMAIL: "agentos@localhost",
29
+ GIT_COMMITTER_NAME: "AgentOS",
30
+ GIT_COMMITTER_EMAIL: "agentos@localhost",
31
+ };
32
+
33
+ export const snapshotRef = (sessionId: string) =>
34
+ `refs/agentos/ended/${sessionId}`;
35
+
36
+ const git = (cwd: string, args: string[], extra: object = {}) =>
37
+ runGit(cwd, args, { timeout: SNAPSHOT_TIMEOUT_MS, ...extra });
38
+
39
+ async function build(worktree: string, sessionId: string): Promise<string> {
40
+ const [head, indexFile] = (
41
+ await git(worktree, [
42
+ "rev-parse",
43
+ "HEAD",
44
+ "--path-format=absolute",
45
+ "--git-path",
46
+ "index",
47
+ ])
48
+ )
49
+ .trim()
50
+ .split("\n");
51
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aos-snap-"));
52
+ try {
53
+ const index = path.join(tmp, "index");
54
+ if (fs.existsSync(indexFile)) {
55
+ fs.copyFileSync(indexFile, index);
56
+ // Git trusts a file's stat only if it is older than the index. A copy
57
+ // is newer, so an edit of the same size made within the index's last
58
+ // timestamp tick would read as unchanged and be missed.
59
+ const stat = fs.statSync(indexFile);
60
+ fs.utimesSync(index, stat.atime, stat.mtime);
61
+ }
62
+ const env = { GIT_INDEX_FILE: index, ...IDENTITY };
63
+ const opts = { env, literalPathspecs: true };
64
+ await git(worktree, ["add", "-u"], opts);
65
+ const untracked = (
66
+ await git(worktree, ["ls-files", "-o", "--exclude-standard", "-z"])
67
+ )
68
+ .split("\0")
69
+ .filter(Boolean)
70
+ .filter((file) => {
71
+ try {
72
+ const stat = fs.statSync(path.join(worktree, file));
73
+ return stat.isFile() && stat.size <= MAX_FILE_BYTES;
74
+ } catch {
75
+ return false;
76
+ }
77
+ })
78
+ .slice(0, MAX_FILES);
79
+ if (untracked.length)
80
+ await git(worktree, ["add", "--", ...untracked], opts);
81
+ if (fs.existsSync(path.join(worktree, PLAN_FILE)))
82
+ await git(worktree, ["add", "-f", "--", PLAN_FILE], opts);
83
+ const tree = (await git(worktree, ["write-tree"], opts)).trim();
84
+ const commit = (
85
+ await git(
86
+ worktree,
87
+ ["commit-tree", tree, "-p", head, "-m", `ended ${sessionId}`],
88
+ opts
89
+ )
90
+ ).trim();
91
+ await git(worktree, ["update-ref", snapshotRef(sessionId), commit]);
92
+ return commit;
93
+ } finally {
94
+ fs.rmSync(tmp, { recursive: true, force: true });
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Saves the worktree's work under a hidden ref. Never throws and never waits
100
+ * past its ceiling: a snapshot that fails only means the restore falls back
101
+ * to the branch.
102
+ */
103
+ export async function snapshotWorktree(
104
+ worktree: string | null,
105
+ sessionId: string
106
+ ): Promise<string | null> {
107
+ if (!worktree || process.env.AGENTOS_NO_END_SNAPSHOT) return null;
108
+ const started = Date.now();
109
+ try {
110
+ // The index can be mid-write when copied; a second read is whole.
111
+ const commit = await build(worktree, sessionId).catch(() =>
112
+ build(worktree, sessionId)
113
+ );
114
+ console.log(
115
+ `[resurrect] snapshot ${sessionId} in ${Date.now() - started} ms`
116
+ );
117
+ return commit;
118
+ } catch (error) {
119
+ console.error(`[resurrect] no snapshot for ${sessionId}:`, error);
120
+ return null;
121
+ }
122
+ }
123
+
124
+ export const hasSnapshot = (repo: string, sessionId: string) =>
125
+ gitOk(repo, ["rev-parse", "--verify", "--quiet", snapshotRef(sessionId)]);
126
+
127
+ export async function dropSnapshot(
128
+ repo: string,
129
+ sessionId: string
130
+ ): Promise<void> {
131
+ await gitOk(repo, ["update-ref", "-d", snapshotRef(sessionId)]);
132
+ }
133
+
134
+ export type RestoredAs = "kept" | "snapshot" | "branch" | "origin" | "fresh";
135
+
136
+ export interface RestoreOptions {
137
+ sessionId: string;
138
+ repo: string;
139
+ branch: string;
140
+ base: string | null;
141
+ // Where it was: used again when free, so Claude's conversation (filed by
142
+ // folder) is found as it is.
143
+ oldPath: string | null;
144
+ // A branch that never held work of its own may be made anew.
145
+ allowFresh: boolean;
146
+ }
147
+
148
+ const addRun = (args: string[], cwd: string) =>
149
+ runGit(cwd, args, { timeout: 120_000 });
150
+
151
+ const refOk = (repo: string, ref: string) =>
152
+ gitOk(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
153
+
154
+ /** The worktree back, or null when there's nothing to make it from. */
155
+ export async function restoreWorktree(
156
+ o: RestoreOptions
157
+ ): Promise<{ path: string; mode: RestoredAs } | null> {
158
+ if (o.oldPath && fs.existsSync(o.oldPath))
159
+ return { path: o.oldPath, mode: "kept" };
160
+ const free = o.oldPath && !fs.existsSync(o.oldPath);
161
+ // Lazily: branch-worktree reaches back into the tasks code that ends sessions.
162
+ const { worktreeDirFor } = await import("../tasks/branch-worktree");
163
+ const dir = free ? o.oldPath! : worktreeDirFor(o.repo, o.branch);
164
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
165
+ await git(o.repo, ["worktree", "prune"]).catch(() => "");
166
+ const snap = snapshotRef(o.sessionId);
167
+ let mode: RestoredAs;
168
+ if (await refOk(o.repo, snap)) {
169
+ await addWorktree(addRun, o.repo, dir, `${snap}^`, ["-B", o.branch]);
170
+ mode = "snapshot";
171
+ } else if (await refOk(o.repo, `refs/heads/${o.branch}`)) {
172
+ await addWorktree(addRun, o.repo, dir, o.branch);
173
+ mode = "branch";
174
+ } else if (await fetchBranch(o.repo, o.branch)) {
175
+ await addWorktree(addRun, o.repo, dir, `origin/${o.branch}`, [
176
+ "-B",
177
+ o.branch,
178
+ ]);
179
+ await git(dir, ["branch", "--set-upstream-to", `origin/${o.branch}`]).catch(
180
+ () => ""
181
+ );
182
+ mode = "origin";
183
+ } else if (o.allowFresh) {
184
+ const base = o.base || "main";
185
+ const start = (await refOk(o.repo, `origin/${base}`))
186
+ ? `origin/${base}`
187
+ : base;
188
+ await addWorktree(addRun, o.repo, dir, start, ["-b", o.branch]);
189
+ mode = "fresh";
190
+ } else return null;
191
+ await installWorktreeHooks(dir).catch(() => "");
192
+ if (mode === "snapshot") await layFiles(dir, snap);
193
+ return { path: dir, mode };
194
+ }
195
+
196
+ async function fetchBranch(repo: string, branch: string): Promise<boolean> {
197
+ const ok = await gitOk(
198
+ repo,
199
+ [
200
+ "fetch",
201
+ "-q",
202
+ "origin",
203
+ `+refs/heads/${branch}:refs/remotes/origin/${branch}`,
204
+ ],
205
+ { timeout: 60_000 }
206
+ );
207
+ return ok && refOk(repo, `origin/${branch}`);
208
+ }
209
+
210
+ // The snapshot's files over the fresh checkout, left as uncommitted changes.
211
+ async function layFiles(dir: string, snap: string): Promise<void> {
212
+ const deleted = (
213
+ await runGit(dir, [
214
+ "diff",
215
+ "--name-only",
216
+ "--diff-filter=D",
217
+ "-z",
218
+ "HEAD",
219
+ snap,
220
+ ])
221
+ )
222
+ .split("\0")
223
+ .filter(Boolean);
224
+ await runGit(dir, ["checkout", snap, "--", "."]);
225
+ await runGit(dir, ["reset", "-q"]);
226
+ for (const file of deleted) fs.rmSync(path.join(dir, file), { force: true });
227
+ // Its .gitignore isn't in the snapshot: without it the plan reads as new.
228
+ if (fs.existsSync(path.join(dir, PLAN_FILE))) preparePlanDir(dir);
229
+ }
@@ -58,6 +58,7 @@ export const STATIC_SIBLINGS = [
58
58
  "detect",
59
59
  "done-idle",
60
60
  "draft",
61
+ "ended",
61
62
  "held-replies",
62
63
  "import",
63
64
  "init",
@@ -2,10 +2,17 @@ import { wakeLabel } from "./snooze";
2
2
 
3
3
  // What a paused row says under its title.
4
4
  export function pauseSubtitle(
5
- paused: { state: "pausing" | "paused" | "resuming"; until: number | null },
5
+ paused: {
6
+ state: "pausing" | "paused" | "resuming";
7
+ until: number | null;
8
+ // Why a requested resume hasn't happened yet.
9
+ note?: string | null;
10
+ },
6
11
  now: number = Date.now(),
7
12
  timeZone?: string
8
13
  ): string {
14
+ if (paused.note && paused.state !== "pausing")
15
+ return paused.note[0].toUpperCase() + paused.note.slice(1);
9
16
  if (paused.state === "pausing") return "Pausing · finishing its step";
10
17
  if (paused.state === "resuming") return "Resuming · waiting for a slot";
11
18
  if (paused.until === null) return "Paused until you resume";
@@ -3,13 +3,17 @@ import type { Session } from "@/lib/db/types";
3
3
  // What a row's ⋯ menu offers to finish it: Done (merge through the gates,
4
4
  // stop, leave it on the Done shelf) for a task, Archive (put it away) for
5
5
  // every session but an orchestrator. A plain session is finished with Mark
6
- // done, so it only gets Archive here.
7
- export type FinishAction = "done" | "archive";
6
+ // done, so it only gets Archive here. A task that is already done is
7
+ // restored instead (its worktree and conversation come back).
8
+ export type FinishAction = "done" | "restore" | "archive";
8
9
 
9
10
  export function finishActions(
10
11
  session: Pick<Session, "role" | "task_status">,
11
12
  plain: boolean
12
13
  ): FinishAction[] {
13
14
  if (session.role === "orchestrator") return [];
14
- return plain ? ["archive"] : ["done", "archive"];
15
+ if (plain) return ["archive"];
16
+ return session.task_status === "done"
17
+ ? ["restore", "archive"]
18
+ : ["done", "archive"];
15
19
  }
@@ -70,7 +70,11 @@ export interface SidebarRow {
70
70
  // Its agent is being stopped, is stopped, or is starting again
71
71
  // (lib/tasks/pause): until is when it resumes by itself (ms), null: when
72
72
  // someone resumes it.
73
- paused?: { state: "pausing" | "paused" | "resuming"; until: number | null };
73
+ paused?: {
74
+ state: "pausing" | "paused" | "resuming";
75
+ until: number | null;
76
+ note?: string | null;
77
+ };
74
78
  }
75
79
 
76
80
  export interface Shelves {
@@ -261,6 +265,7 @@ function toRow(input: ShelfInput, session: Session, now: number): SidebarRow {
261
265
  paused: {
262
266
  state: pauseState,
263
267
  until: session.paused_until ? ms(session.paused_until) : null,
268
+ ...(session.pause_note && { note: session.pause_note }),
264
269
  },
265
270
  }),
266
271
  working:
@@ -421,6 +426,7 @@ export function sameRow(a: SidebarRow, b: SidebarRow): boolean {
421
426
  a.wakesAt === b.wakesAt &&
422
427
  a.paused?.state === b.paused?.state &&
423
428
  a.paused?.until === b.paused?.until &&
429
+ a.paused?.note === b.paused?.note &&
424
430
  a.settled === b.settled &&
425
431
  a.workers.length === b.workers.length &&
426
432
  a.workers.every((w, i) => sameRow(w, b.workers[i])) &&
@@ -0,0 +1,17 @@
1
+ import { brakesEnabled } from "../orchestrator/brakes";
2
+ import { isPaused } from "../orchestrator/pause";
3
+ import { readUsage, windowRefusal } from "../orchestrator/usage";
4
+ import type { Hold } from "./schedule";
5
+
6
+ // Why an automatic start has to wait, or null. A start now by a person
7
+ // isn't held.
8
+ export function autoHold(
9
+ workspaceId: string | null,
10
+ planOnly = false
11
+ ): Hold | null {
12
+ if (workspaceId && isPaused(workspaceId))
13
+ return { code: "orch_paused", text: "Waiting: the orchestrator is paused" };
14
+ if (planOnly) return null;
15
+ const window = brakesEnabled() ? windowRefusal(readUsage()) : null;
16
+ return window ? { code: "window", text: `Waiting: ${window}` } : null;
17
+ }
@@ -26,6 +26,7 @@ import { getProject } from "../projects";
26
26
  import { canSignOff } from "./state";
27
27
  import {
28
28
  forgetPR,
29
+ expandHome,
29
30
  getTaskSession,
30
31
  isAllowedBase,
31
32
  prFor,
@@ -34,6 +35,7 @@ import {
34
35
  import { dropSessionDatabase } from "../project-config/database";
35
36
  import { captureMergedTask } from "../session-logs";
36
37
  import { sessionEnded } from "../experiments/hooks";
38
+ import { snapshotWorktree } from "../resurrect/snapshot";
37
39
 
38
40
  // Never deleted, whatever a setting says.
39
41
  export const PROTECTED_BRANCHES = new Set(["main", "master", "HEAD"]);
@@ -233,6 +235,12 @@ export async function dropTask(id: string): Promise<void> {
233
235
  `${names} ${children.length === 1 ? "is" : "are"} stacked on this task and built on its branch. Drop ${children.length === 1 ? "it" : "them"} first, or sign this one off.`
234
236
  );
235
237
  }
238
+ // Saved while the PR is closed below, and settled before the worktree goes:
239
+ // a restore finds its uncommitted files and plan here. Never throws.
240
+ const snapshot = snapshotWorktree(
241
+ session.worktree_path && expandHome(session.worktree_path),
242
+ id
243
+ );
236
244
  const pr = await prFor(session, true);
237
245
  if (pr?.state === "OPEN") {
238
246
  await run("gh", ["pr", "close", String(pr.number)], repo).catch(() => {});
@@ -245,5 +253,6 @@ export async function dropTask(id: string): Promise<void> {
245
253
  `UPDATE stack_items SET status = 'dropped' WHERE session_id = ?`
246
254
  ).run(id);
247
255
  syncTaskCardInBackground(session, "dropped", pr);
256
+ await snapshot;
248
257
  await cleanup(session, repo);
249
258
  }
@@ -46,7 +46,7 @@ export function endPause(
46
46
  WHERE id = ? AND state != 'ended'`
47
47
  ).run(reason, row.id);
48
48
  db.prepare(
49
- `UPDATE sessions SET pause_state = NULL, paused_until = NULL, pause_id = NULL
49
+ `UPDATE sessions SET pause_state = NULL, paused_until = NULL, pause_id = NULL, pause_note = NULL
50
50
  WHERE id = ? AND pause_id = ?`
51
51
  ).run(row.session_id, row.id);
52
52
  })();
@@ -34,6 +34,9 @@ import { openBySubject, workSubject } from "../orchestrator/asks";
34
34
  import { queueEventOnce } from "../orchestrator/events";
35
35
  import { isPaused } from "../orchestrator/pause";
36
36
  import { isMerging } from "./finish";
37
+ import { autoHold } from "./auto-hold";
38
+ import { localView } from "../load/machines";
39
+ import { machineHold } from "./schedule";
37
40
  import {
38
41
  continuePrompt,
39
42
  durationText,
@@ -373,7 +376,8 @@ export function resumeBlock(sessionId: string, actor: PauseBy): string | null {
373
376
  }
374
377
 
375
378
  // Marks a paused session's resume as due: a task then competes for a slot
376
- // (queue tick), anything else resumes at once.
379
+ // (queue tick), anything else resumes at once. Only the timer uses this for
380
+ // a task; a person or the orchestrator resuming goes through resumeNow.
377
381
  export function requestResume(sessionId: string, actor: PauseBy): void {
378
382
  const refusal = resumeBlock(sessionId, actor);
379
383
  if (refusal) throw new PauseRefused(refusal);
@@ -391,13 +395,73 @@ export function requestResume(sessionId: string, actor: PauseBy): void {
391
395
  );
392
396
  }
393
397
 
394
- // A person's Resume now: past the slot limit, like Start now.
395
- export async function resumeNow(sessionId: string, actor: PauseBy = "person") {
398
+ // Why this machine's load rules out starting an agent now, or null. Only a
399
+ // red (or still settling) machine holds a resume; amber doesn't.
400
+ export function loadHoldFor(s: Session): string | null {
401
+ const project = s.project_id ? getProject(s.project_id) : null;
402
+ if (!project || isRemoteHost(s.host_id ?? project.host_id)) return null;
403
+ const hold = machineHold(
404
+ {
405
+ heavy: false,
406
+ needsUnmet: false,
407
+ remote: false,
408
+ priority: DEFAULT_PRIORITY,
409
+ },
410
+ localView(),
411
+ Date.now()
412
+ );
413
+ return hold?.code === "load_red" ? hold.text.replace(/^Waiting: /, "") : null;
414
+ }
415
+
416
+ // Why a resume this actor asked for must wait: this machine's load, and for
417
+ // the orchestrator also what holds its own starts (it is paused, or the
418
+ // usage window). Saad's own resume skips the second.
419
+ export function resumeHoldFor(s: Session, actor: PauseBy): string | null {
420
+ const load = loadHoldFor(s);
421
+ if (load) return load;
422
+ if (actor !== "orchestrator") return null;
423
+ const ws = s.project_id ? getProject(s.project_id)?.workspace_id : null;
424
+ return autoHold(ws ?? null)?.text.replace(/^Waiting: /, "") ?? null;
425
+ }
426
+
427
+ export function setPauseNote(sessionId: string, note: string | null): void {
428
+ db.prepare(
429
+ `UPDATE sessions SET pause_note = ? WHERE id = ? AND pause_state = 'paused' AND COALESCE(pause_note, '') != COALESCE(?, '')`
430
+ ).run(note, sessionId, note);
431
+ }
432
+
433
+ export type ResumeOutcome =
434
+ | { outcome: "resumed" | "cancelled" }
435
+ | { outcome: "held"; note: string };
436
+
437
+ // A deliberate Resume, by a person or the orchestrator: starts at once, past
438
+ // the slot limit, like Start now. A machine in the red still holds it: it is
439
+ // recorded as asked for, says why on the task, and goes when the load
440
+ // calms.
441
+ export async function resumeNow(
442
+ sessionId: string,
443
+ actor: PauseBy = "person"
444
+ ): Promise<ResumeOutcome> {
396
445
  const refusal = resumeBlock(sessionId, actor);
397
446
  if (refusal) throw new PauseRefused(refusal);
398
447
  const row = activePause(sessionId)!;
399
- if (row.state === "pausing") return cancelPause(sessionId);
448
+ if (row.state === "pausing") {
449
+ cancelPause(sessionId);
450
+ return { outcome: "cancelled" };
451
+ }
452
+ const s = sessionOf(sessionId);
453
+ const why = s ? resumeHoldFor(s, actor) : null;
454
+ if (s && why && row.state === "paused") {
455
+ db.prepare(
456
+ `UPDATE task_pauses SET resume_requested_at = COALESCE(resume_requested_at, datetime('now')), resume_by_kind = ? WHERE id = ?`
457
+ ).run(actor, row.id);
458
+ const note = `held: ${why}`;
459
+ setPauseNote(sessionId, note);
460
+ nudge();
461
+ return { outcome: "held", note };
462
+ }
400
463
  await claimResume(sessionId, { reason: actor, force: true });
464
+ return { outcome: "resumed" };
401
465
  }
402
466
 
403
467
  export interface DueResume {
@@ -407,6 +471,8 @@ export interface DueResume {
407
471
  priority: Priority;
408
472
  dueAtMs: number;
409
473
  byKind: PauseBy;
474
+ // A person or the orchestrator asked: it isn't queued behind the slot limit.
475
+ resumeBy: PauseBy | null;
410
476
  }
411
477
 
412
478
  // Paused tasks whose time came (or that someone asked to resume), for the
@@ -414,7 +480,7 @@ export interface DueResume {
414
480
  export function dueResumes(): DueResume[] {
415
481
  const rows = db
416
482
  .prepare(
417
- `SELECT p.id AS pause_id, p.session_id, p.by_kind, p.until_at, p.resume_requested_at,
483
+ `SELECT p.id AS pause_id, p.session_id, p.by_kind, p.until_at, p.resume_requested_at, p.resume_by_kind,
418
484
  s.project_id, s.priority
419
485
  FROM task_pauses p JOIN sessions s ON s.id = p.session_id
420
486
  WHERE p.state = 'paused' AND p.resume_requested_at IS NOT NULL
@@ -426,6 +492,7 @@ export function dueResumes(): DueResume[] {
426
492
  by_kind: PauseBy;
427
493
  until_at: string | null;
428
494
  resume_requested_at: string;
495
+ resume_by_kind: string | null;
429
496
  project_id: string | null;
430
497
  priority: string | null;
431
498
  }[];
@@ -438,6 +505,7 @@ export function dueResumes(): DueResume[] {
438
505
  priority: isPriority(r.priority) ? r.priority : DEFAULT_PRIORITY,
439
506
  dueAtMs: r.until_at ? ms(r.until_at) : ms(r.resume_requested_at),
440
507
  byKind: r.by_kind,
508
+ resumeBy: (r.resume_by_kind as PauseBy | null) ?? null,
441
509
  }));
442
510
  }
443
511
 
@@ -544,9 +612,9 @@ export async function claimResume(
544
612
  }),
545
613
  Date.now()
546
614
  );
547
- db.prepare(`UPDATE sessions SET pause_state = 'resuming' WHERE id = ?`).run(
548
- sessionId
549
- );
615
+ db.prepare(
616
+ `UPDATE sessions SET pause_state = 'resuming', pause_note = NULL WHERE id = ?`
617
+ ).run(sessionId);
550
618
  return true;
551
619
  })();
552
620
  if (!claimed) return "gone";
@@ -22,6 +22,7 @@ import { getProject } from "../projects";
22
22
  import { isRemoteHost } from "../hosts";
23
23
  import { getWorkspace } from "../workspaces";
24
24
  import { isPaused } from "../orchestrator/pause";
25
+ import { autoHold } from "./auto-hold";
25
26
  import {
26
27
  BrakeRefused,
27
28
  braked,
@@ -77,7 +78,13 @@ import { peerReading, peerSource, refreshPeers } from "../hosts/peer-load";
77
78
  import { hostLink, linkedHostIds } from "../hosts/remote-api";
78
79
  import { hostTasks } from "./remote";
79
80
  import { projectMachines } from "../hosts/project-machines";
80
- import { claimResume, dueResumes } from "./pause";
81
+ import {
82
+ type DueResume,
83
+ claimResume,
84
+ dueResumes,
85
+ resumeHoldFor,
86
+ setPauseNote,
87
+ } from "./pause";
81
88
 
82
89
  export type QueueStatus =
83
90
  | "queued"
@@ -562,16 +569,6 @@ const sqliteMs = (v: string) =>
562
569
  export const queuedView = (id: string) =>
563
570
  listQueue().find((q) => q.id === id) ?? null;
564
571
 
565
- // Why an automatic start has to wait, or null. A start now by a person
566
- // isn't held.
567
- function autoHold(workspaceId: string | null, planOnly = false): Hold | null {
568
- if (workspaceId && isPaused(workspaceId))
569
- return { code: "orch_paused", text: "Waiting: the orchestrator is paused" };
570
- if (planOnly) return null;
571
- const window = brakesEnabled() ? windowRefusal(readUsage()) : null;
572
- return window ? { code: "window", text: `Waiting: ${window}` } : null;
573
- }
574
-
575
572
  const requestNeeds = (
576
573
  projectId: string,
577
574
  workingDirectory: string,
@@ -959,7 +956,18 @@ export async function tickOnce(now = Date.now()): Promise<"stale" | void> {
959
956
  WHERE q.status IN ('queued', 'starting') ORDER BY q.position`
960
957
  )
961
958
  .all() as QueueRowWithWorkspace[];
962
- const resumes = dueResumes();
959
+ const due = dueResumes();
960
+ // A deliberate resume starts past the slot limit; only a red machine holds
961
+ // it, and the task says so.
962
+ for (const d of due.filter((x) => x.resumeBy)) {
963
+ const s = db
964
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
965
+ .get(d.sessionId) as Session | undefined;
966
+ const why = s ? resumeHoldFor(s, d.resumeBy!) : null;
967
+ if (why) setPauseNote(d.sessionId, `held: ${why}`);
968
+ else await claimResume(d.sessionId, { reason: d.resumeBy!, force: true });
969
+ }
970
+ const resumes = due.filter((x) => !x.resumeBy);
963
971
  if (!rows.length && !resumes.length) return;
964
972
  const queued = rows.filter((r) => r.status === "queued");
965
973
  const used = usedFinishes();
@@ -1051,6 +1059,11 @@ export async function tickOnce(now = Date.now()): Promise<"stale" | void> {
1051
1059
  const plan = planStarts(candidates, groups, home, now);
1052
1060
  for (const r of queued)
1053
1061
  noteWaiting(r, withStayReason(r, plan.explain.get(r.id)));
1062
+ for (const d of resumes)
1063
+ setPauseNote(
1064
+ d.sessionId,
1065
+ resumeWaitNote(d, plan.explain.get(`resume:${d.sessionId}`))
1066
+ );
1054
1067
 
1055
1068
  for (const id of plan.start) {
1056
1069
  if (id.startsWith("resume:")) {
@@ -1067,6 +1080,20 @@ export async function tickOnce(now = Date.now()): Promise<"stale" | void> {
1067
1080
  }
1068
1081
  }
1069
1082
 
1083
+ // What a timed resume still waiting for its turn says on its task.
1084
+ function resumeWaitNote(
1085
+ d: DueResume,
1086
+ explain: { code: WaitCode | null; text: string | null } | undefined
1087
+ ): string | null {
1088
+ if (!explain) return null;
1089
+ if (explain.code && explain.code !== "slot" && explain.text)
1090
+ return `held: ${explain.text.replace(/^Waiting: /, "")}`;
1091
+ const slots = slotState(d.projectId);
1092
+ return slots.limit === null
1093
+ ? "resuming: waiting for its turn"
1094
+ : `resuming: waiting for a slot, ${slots.running} of ${slots.limit} running`;
1095
+ }
1096
+
1070
1097
  // Writes why a row waits when that changed: its hold text and code. A bare
1071
1098
  // rank isn't stored (the view computes it), so the stream stays quiet.
1072
1099
  const MACHINE_HOLDS = new Set<WaitCode>([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.592",
3
+ "version": "0.3.594",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"