@saadnvd1/agent-os 0.3.592 → 0.3.595
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/app/api/bus/resurrect/route.test.ts +47 -0
- package/app/api/bus/resurrect/route.ts +36 -0
- package/app/api/sessions/[id]/pause/route.ts +5 -2
- package/app/api/sessions/[id]/resurrect/route.test.ts +32 -0
- package/app/api/sessions/[id]/resurrect/route.ts +24 -0
- package/app/api/sessions/ended/route.ts +19 -0
- package/components/Archived/ArchivedRow.tsx +11 -7
- package/components/Chat/PausedBanner.tsx +1 -1
- package/components/Providers.tsx +5 -1
- package/components/SessionList/LiveLine.tsx +1 -1
- package/components/SessionList/OrchestratorPinRow.tsx +10 -5
- package/components/SessionList/QuickChangeRow.tsx +1 -1
- package/components/SessionList/RowParts.tsx +37 -2
- package/components/SessionList/RowShell.tsx +6 -2
- package/components/SessionList/RowSubtitle.tsx +20 -12
- package/components/SessionList/SessionRow.tsx +28 -17
- package/components/SessionList/SessionRowMenu.tsx +12 -1
- package/components/SessionList/SessionShelves.tsx +40 -8
- package/components/SessionList/SidebarExtras.tsx +7 -1
- package/components/SessionList/index.tsx +2 -0
- package/components/SessionList/useDoneAction.ts +14 -0
- package/components/SessionList/useResurrectAction.test.ts +96 -0
- package/components/SessionList/useSessionTips.ts +62 -0
- package/components/Tasks/TaskRow.tsx +18 -0
- package/components/ui/HoverTip.tsx +20 -0
- package/data/done/index.ts +17 -0
- package/data/presence/index.ts +14 -7
- package/lib/db/migrations.ts +11 -0
- package/lib/db/types.ts +2 -0
- package/lib/done/worktree.ts +18 -2
- package/lib/hover-tip.ts +161 -0
- package/lib/orchestrator/act.ts +44 -5
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +2 -1
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +6 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/resurrect/find.ts +111 -0
- package/lib/resurrect/index.ts +422 -0
- package/lib/resurrect/plan.ts +61 -0
- package/lib/resurrect/snapshot.ts +229 -0
- package/lib/security/demo.ts +1 -0
- package/lib/sidebar/paused.ts +8 -1
- package/lib/sidebar/row-actions.ts +7 -3
- package/lib/sidebar/row-badge.ts +40 -0
- package/lib/sidebar/row-details.ts +53 -0
- package/lib/sidebar/shelves.ts +7 -1
- package/lib/tasks/auto-hold.ts +17 -0
- package/lib/tasks/finish.ts +9 -0
- package/lib/tasks/pause-hold.ts +1 -1
- package/lib/tasks/pause.ts +76 -8
- package/lib/tasks/queue.ts +39 -12
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/security/demo.ts
CHANGED
package/lib/sidebar/paused.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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
|
-
|
|
15
|
+
if (plain) return ["archive"];
|
|
16
|
+
return session.task_status === "done"
|
|
17
|
+
? ["restore", "archive"]
|
|
18
|
+
: ["done", "archive"];
|
|
15
19
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The plan badge a row shows on its right: writing its plan, or a plan
|
|
2
|
+
// waiting for approval. A need or a stall says more and wins; finished,
|
|
3
|
+
// snoozed and quick-change rows show none. Client-safe.
|
|
4
|
+
import type { SidebarRow } from "./shelves";
|
|
5
|
+
|
|
6
|
+
export type RowKindBadge = {
|
|
7
|
+
kind: "planning" | "plan-ready";
|
|
8
|
+
label: string;
|
|
9
|
+
// What it means and what to do: the badge's hover text.
|
|
10
|
+
tip: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function rowKindBadge(
|
|
14
|
+
row: Pick<SidebarRow, "need" | "task" | "settled" | "wakesAt"> & {
|
|
15
|
+
session: SidebarRow["session"];
|
|
16
|
+
status?: SidebarRow["status"];
|
|
17
|
+
}
|
|
18
|
+
): RowKindBadge | null {
|
|
19
|
+
const s = row.session;
|
|
20
|
+
if (row.need || row.status?.stalled) return null;
|
|
21
|
+
if (row.settled || row.wakesAt || s.quick_of || s.role) return null;
|
|
22
|
+
if (s.task_status && s.task_status !== "running") return null;
|
|
23
|
+
if (row.task === "plan-ready" || s.plan_phase === "awaiting")
|
|
24
|
+
return {
|
|
25
|
+
kind: "plan-ready",
|
|
26
|
+
label: "Plan ready",
|
|
27
|
+
tip: "Plan ready: waiting for your approval. Open it to read the plan.",
|
|
28
|
+
};
|
|
29
|
+
if (s.plan_phase === "planning" || (!s.plan_phase && !!Number(s.chat_plan)))
|
|
30
|
+
return {
|
|
31
|
+
kind: "planning",
|
|
32
|
+
label: "Planning",
|
|
33
|
+
tip: s.plan_only
|
|
34
|
+
? "Planning: plan only, it stops at the plan for your approval."
|
|
35
|
+
: s.plan_phase
|
|
36
|
+
? "Planning: writing its plan, then it implements."
|
|
37
|
+
: "Plan mode: it plans and changes nothing yet.",
|
|
38
|
+
};
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// What a session row's hover tip says: its full name, then what the row
|
|
2
|
+
// abbreviates. Built when the tip opens, never on render. Client-safe.
|
|
3
|
+
import type { TipContent } from "@/lib/hover-tip";
|
|
4
|
+
import { rowGroup } from "@/lib/recipes/chip";
|
|
5
|
+
import { compactTimeAgo } from "@/lib/session-meta";
|
|
6
|
+
import { prStageText } from "@/lib/tasks/pr-stage";
|
|
7
|
+
import { NEED_LABEL, type SidebarRow } from "./shelves";
|
|
8
|
+
import { pauseSubtitle } from "./paused";
|
|
9
|
+
import { rowKindBadge } from "./row-badge";
|
|
10
|
+
|
|
11
|
+
export function rowState(row: SidebarRow, now: number): string {
|
|
12
|
+
if (row.need) return `Needs you: ${NEED_LABEL[row.need]}`;
|
|
13
|
+
if (row.status?.stalled) return "Stalled";
|
|
14
|
+
const badge = rowKindBadge(row);
|
|
15
|
+
if (badge?.kind === "plan-ready") return "Plan ready, waiting for approval";
|
|
16
|
+
if (badge?.kind === "planning")
|
|
17
|
+
return row.session.plan_only ? "Planning (plan only)" : "Planning";
|
|
18
|
+
if (row.paused) return pauseSubtitle(row.paused, now);
|
|
19
|
+
if (row.compacting) return "Compacting its context";
|
|
20
|
+
const pr = row.status?.prStage;
|
|
21
|
+
if (pr) return prStageText(pr);
|
|
22
|
+
if (row.working) return "Working";
|
|
23
|
+
if (row.wakesAt) return "Snoozed";
|
|
24
|
+
return row.settled ? "Done" : "Idle";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function rowDetails(
|
|
28
|
+
row: SidebarRow,
|
|
29
|
+
names: {
|
|
30
|
+
project?: string;
|
|
31
|
+
startedBy?: string;
|
|
32
|
+
host?: string;
|
|
33
|
+
},
|
|
34
|
+
now: number = Date.now()
|
|
35
|
+
): TipContent {
|
|
36
|
+
const s = row.session;
|
|
37
|
+
const lines: TipContent["lines"] = [["State", rowState(row, now)]];
|
|
38
|
+
if (names.project) lines.push(["Project", names.project]);
|
|
39
|
+
const group = rowGroup(s);
|
|
40
|
+
if (group)
|
|
41
|
+
lines.push([group.kind === "recipe" ? "Recipe" : "Plan", group.text]);
|
|
42
|
+
if (s.pr_number) lines.push(["PR", `#${s.pr_number}`]);
|
|
43
|
+
if (names.host) lines.push(["Machine", names.host]);
|
|
44
|
+
if (names.startedBy) lines.push(["Started by", names.startedBy]);
|
|
45
|
+
if (row.activeAt) {
|
|
46
|
+
const ago = compactTimeAgo(new Date(row.activeAt), new Date(now));
|
|
47
|
+
lines.push([
|
|
48
|
+
"Last active",
|
|
49
|
+
ago === "now" ? "just now" : /^\d+[mhd]$/.test(ago) ? `${ago} ago` : ago,
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
return { title: s.name, lines };
|
|
53
|
+
}
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -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?: {
|
|
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
|
+
}
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -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
|
}
|
package/lib/tasks/pause-hold.ts
CHANGED
|
@@ -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
|
})();
|
package/lib/tasks/pause.ts
CHANGED
|
@@ -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
|
-
//
|
|
395
|
-
|
|
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")
|
|
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(
|
|
548
|
-
|
|
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";
|