@saadnvd1/agent-os 0.3.853 → 0.3.859
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/leads/adopt/route.ts +35 -0
- package/app/api/leads/proposals/route.ts +52 -0
- package/app/api/leads/route.test.ts +95 -0
- package/app/api/leads/route.ts +60 -0
- package/app/api/sessions/[id]/fresh/route.ts +2 -1
- package/app/api/sessions/[id]/route.ts +9 -1
- package/app/api/tmux/kill-all/route.ts +2 -1
- package/app/api/workspaces/[id]/orchestrator/tools/route.test.ts +43 -0
- package/app/api/workspaces/[id]/orchestrator/tools/route.ts +14 -11
- package/components/Analyst/pair.ts +2 -1
- package/components/Chat/ChatPanel.tsx +1 -0
- package/components/Orchestrator/AskCard.tsx +6 -0
- package/components/Orchestrator/NeedsYouStrip.tsx +7 -2
- package/components/Pane/index.tsx +5 -7
- package/components/Schedules/ScheduleForm.tsx +2 -1
- package/components/SessionList/RowSubtitle.tsx +2 -1
- package/components/SessionList/SessionRow.tsx +2 -1
- package/components/SessionList/useSidebarData.ts +8 -3
- package/content/whats-new.json +16 -0
- package/hooks/useNeedsYou.ts +10 -4
- package/hooks/useOrchestratorHeader.ts +2 -1
- package/lib/analyst/createLink.ts +3 -1
- package/lib/away/report.ts +2 -1
- package/lib/bus/format.ts +4 -1
- package/lib/bus/index.ts +9 -2
- package/lib/bus/read-scope.ts +4 -3
- package/lib/bus/resolve.ts +4 -1
- package/lib/bus/role-alias.ts +2 -1
- package/lib/bus/targets.ts +8 -3
- package/lib/bus/waits/tick.ts +2 -1
- package/lib/chat/compact-context.ts +2 -1
- package/lib/chat/mention-roots.ts +2 -1
- package/lib/chat/runner.ts +3 -2
- package/lib/chat/settings.ts +3 -2
- package/lib/chat/worker/extras.ts +4 -1
- package/lib/chat/worker/host.ts +6 -5
- package/lib/db/migrations/20261102T1944_leads.ts +142 -0
- package/lib/db/migrations/20261102T2300_needs_you_chat_per_session.ts +24 -0
- package/lib/db/session-parts/columns.ts +2 -0
- package/lib/db/types.ts +10 -2
- package/lib/done/archive.ts +3 -2
- package/lib/done/auto-archive.ts +2 -1
- package/lib/done/bulk.ts +4 -3
- package/lib/done/index.ts +3 -2
- package/lib/done/plan.ts +5 -2
- package/lib/experiments/registry.ts +11 -0
- package/lib/experiments/view.ts +3 -1
- package/lib/fork/brief.ts +2 -2
- package/lib/fork/extras.ts +4 -2
- package/lib/fork/guard.ts +4 -2
- package/lib/fork/scratch.ts +102 -12
- package/lib/fork/settle.ts +31 -7
- package/lib/fork/start.ts +5 -4
- package/lib/fork/tick.ts +19 -0
- package/lib/fork/worktree.ts +7 -3
- package/lib/health/env/sendFixNotes.ts +3 -2
- package/lib/hosts/peer-actions.ts +2 -1
- package/lib/hosts/peer-sessions.ts +3 -1
- package/lib/leads/actor.ts +16 -0
- package/lib/leads/adopt.ts +150 -0
- package/lib/leads/area.ts +77 -0
- package/lib/leads/brief-common.ts +122 -0
- package/lib/leads/brief.ts +155 -0
- package/lib/leads/charters.ts +99 -0
- package/lib/leads/deliver.ts +24 -0
- package/lib/leads/digest.ts +166 -0
- package/lib/leads/direction.ts +202 -0
- package/lib/leads/home.ts +196 -0
- package/lib/leads/index.ts +8 -0
- package/lib/leads/memos.ts +70 -0
- package/lib/leads/messages.ts +112 -0
- package/lib/leads/profiles.ts +83 -0
- package/lib/leads/roles.ts +114 -0
- package/lib/leads/settings.ts +55 -0
- package/lib/leads/templates.ts +51 -0
- package/lib/leads/tools.ts +159 -0
- package/lib/leads/types.ts +88 -0
- package/lib/leads/usage.ts +46 -0
- package/lib/learning-cards/input.ts +2 -1
- package/lib/live/drive.ts +2 -1
- package/lib/models/launch.ts +2 -1
- package/lib/models/session-role.ts +2 -1
- package/lib/needs-you.ts +12 -6
- package/lib/notify/index.ts +2 -1
- package/lib/orchestrator/act.ts +3 -1
- package/lib/orchestrator/ask-tool.ts +10 -0
- package/lib/orchestrator/ask-view.ts +2 -0
- package/lib/orchestrator/asks.ts +48 -8
- package/lib/orchestrator/brief.ts +32 -67
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/facts.ts +2 -1
- package/lib/orchestrator/full-access.ts +2 -1
- package/lib/orchestrator/home.ts +8 -7
- package/lib/orchestrator/mcp-servers.ts +7 -6
- package/lib/orchestrator/notes.ts +15 -4
- package/lib/orchestrator/overview.ts +3 -1
- package/lib/orchestrator/recipe-tools.ts +2 -1
- package/lib/orchestrator/reminders.ts +2 -1
- package/lib/orchestrator/tools.ts +43 -13
- package/lib/orchestrator/worker.ts +45 -13
- package/lib/orchestrator/write-guard.ts +2 -2
- package/lib/projects.ts +3 -2
- package/lib/resurrect/find.ts +2 -1
- package/lib/resurrect/index.ts +3 -2
- package/lib/schedules/index.ts +3 -2
- package/lib/schedules/start.ts +2 -1
- package/lib/schedules/store.ts +3 -2
- package/lib/session-audit/sources.ts +4 -3
- package/lib/session-groups/workspace.ts +2 -1
- package/lib/sessions/launch.ts +2 -1
- package/lib/sidebar/machines.ts +2 -1
- package/lib/sidebar/row-actions.ts +2 -1
- package/lib/sidebar/row-badge.ts +3 -1
- package/lib/sidebar/row-menu.ts +6 -3
- package/lib/sidebar/shelves.ts +3 -3
- package/lib/status/collect.ts +4 -3
- package/lib/tasks/held-replies.ts +2 -1
- package/lib/tasks/move-targets.ts +2 -1
- package/lib/tasks/pause.ts +2 -1
- package/lib/tasks/starter.ts +3 -2
- package/lib/workspaces.ts +4 -3
- package/lib/worktree-sweep/findSettled.ts +2 -1
- package/package.json +1 -1
package/lib/done/index.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* its own action, archive.ts). What it will do is decided first (plan.ts).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { isStandingRole, RoleLabel } from "../leads/roles";
|
|
11
12
|
import { endPauseOnFinish } from "../tasks/pause-hold";
|
|
12
13
|
import { db, queries, type Session } from "../db";
|
|
13
14
|
import { endSessionAgent } from "../sessions/end";
|
|
@@ -132,8 +133,8 @@ export async function doneSession(
|
|
|
132
133
|
// A demo archives and nothing more: no merge, no agent to stop, no
|
|
133
134
|
// worktree to touch.
|
|
134
135
|
if (demoMode()) {
|
|
135
|
-
if (s.role
|
|
136
|
-
throw new Error(
|
|
136
|
+
if (isStandingRole(s.role))
|
|
137
|
+
throw new Error(`${RoleLabel(s.role)} isn't marked done.`);
|
|
137
138
|
if (s.archived_at) throw new Error(`${s.name} is already archived.`);
|
|
138
139
|
if (s.task_prompt) setTask(db, s.id, { task_status: "done" });
|
|
139
140
|
archiveSession(s.id);
|
package/lib/done/plan.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* single done acts on it; the bulk preview shows it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { isStandingRole, roleNoun } from "../leads/roles";
|
|
7
8
|
import { db, stackQueries as sq, type Session } from "../db";
|
|
8
9
|
import type { TaskPR } from "../tasks";
|
|
9
10
|
import { prFor } from "../tasks/session";
|
|
@@ -90,8 +91,10 @@ export async function planDone(
|
|
|
90
91
|
s: Session,
|
|
91
92
|
callerId?: string | null
|
|
92
93
|
): Promise<DonePlan> {
|
|
93
|
-
if (s.role
|
|
94
|
-
return refuse(
|
|
94
|
+
if (isStandingRole(s.role))
|
|
95
|
+
return refuse(
|
|
96
|
+
`${s.name} is a workspace's ${roleNoun(s.role)}; it can't be done`
|
|
97
|
+
);
|
|
95
98
|
if (s.archived_at) return refuse(`${s.name} is already archived`);
|
|
96
99
|
if (callerId && callerId === s.id)
|
|
97
100
|
return refuse("A session can't mark itself done");
|
|
@@ -14,6 +14,8 @@ export interface Experiment {
|
|
|
14
14
|
status: ExperimentStatus;
|
|
15
15
|
// It shows suggestions, so an accept rate means something for it.
|
|
16
16
|
suggests?: boolean;
|
|
17
|
+
// Built but not offered in Settings yet: it can be set, nothing lists it.
|
|
18
|
+
unlisted?: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export const EXPERIMENTS = [
|
|
@@ -34,6 +36,15 @@ export const EXPERIMENTS = [
|
|
|
34
36
|
area: "sessions",
|
|
35
37
|
status: "experimental",
|
|
36
38
|
},
|
|
39
|
+
{
|
|
40
|
+
id: "leads",
|
|
41
|
+
name: "Leads",
|
|
42
|
+
description:
|
|
43
|
+
"Department heads and a chief of staff, per workspace. Off, nothing changes. On, you choose which workspaces get leads; each gets its own chief.",
|
|
44
|
+
area: "orchestrator",
|
|
45
|
+
status: "experimental",
|
|
46
|
+
unlisted: true,
|
|
47
|
+
},
|
|
37
48
|
] as const satisfies readonly Experiment[];
|
|
38
49
|
|
|
39
50
|
export type ExperimentId = (typeof EXPERIMENTS)[number]["id"];
|
package/lib/experiments/view.ts
CHANGED
|
@@ -15,5 +15,7 @@ export const experimentView = (id: ExperimentId): ExperimentView => ({
|
|
|
15
15
|
|
|
16
16
|
export const experimentsView = () => ({
|
|
17
17
|
locked: experimentsLocked(),
|
|
18
|
-
experiments: EXPERIMENTS.
|
|
18
|
+
experiments: EXPERIMENTS.filter((e: Experiment) => !e.unlisted).map((e) =>
|
|
19
|
+
experimentView(e.id)
|
|
20
|
+
),
|
|
19
21
|
});
|
package/lib/fork/brief.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { FORK_LIMITS } from "./options";
|
|
4
4
|
import type { ChildRow } from "./types";
|
|
5
5
|
|
|
6
|
-
export const PARENT_BRIEF = `Forks: when the user asks you to fork, or to do things in parallel in background copies, run \`aos fork "<instruction>" ...\` (one instruction per fork), keep working, and results arrive as a message. Also when you have independent side work, \`aos fork "<instruction>" ["<instruction>" …]\` starts that many background copies of you (up to ${FORK_LIMITS.maxPerFork}) that know what you know. It returns at once; you keep working. Their results arrive as a message when they finish, or after \`aos wait children --all|--any [--timeout 30m]\` (end your turn; you're woken with them). \`aos children\` lists them, \`aos children <n>\` shows a full result, \`aos cancel <n>|--all\` stops them. Children have your abilities and work in your checkout: tell one that has to edit code to \`--worktree\` (its own
|
|
6
|
+
export const PARENT_BRIEF = `Forks: when the user asks you to fork, or to do things in parallel in background copies, run \`aos fork "<instruction>" ...\` (one instruction per fork), keep working, and results arrive as a message. Also when you have independent side work, \`aos fork "<instruction>" ["<instruction>" …]\` starts that many background copies of you (up to ${FORK_LIMITS.maxPerFork}) that know what you know. It returns at once; you keep working. Their results arrive as a message when they finish, or after \`aos wait children --all|--any [--timeout 30m]\` (end your turn; you're woken with them). \`aos children\` lists them, \`aos children <n>\` shows a full result, \`aos cancel <n>|--all\` stops them. Children have your abilities and work in your checkout: tell one that has to edit code to \`--worktree\` (its own checkout). Fork only work that doesn't depend on your next step.`;
|
|
7
7
|
|
|
8
8
|
export const CHILD_BRIEF = `You are a fork child: a background copy of another session, started to do one piece of work and report back. You have its abilities, not its checkout: don't change the parent's files, push, open a PR or fork. Put temp work (scripts, repros, notes) in $AOS_SCRATCH. Run builds and tests only through \`aos heavy -- <command>\`, which queues you with the other forks. To ask the parent something, \`aos send <parent> "<question>"\`; that doesn't finish you. When done, \`aos return "<result>"\` (first line: the answer in a sentence; then the detail; \`--failed\` if you couldn't do it). Your reply when you end your turn without it is used as the result, after a short wait.`;
|
|
9
9
|
|
|
@@ -22,7 +22,7 @@ export function childFirstMessage(opts: {
|
|
|
22
22
|
opts.instruction.trim(),
|
|
23
23
|
"",
|
|
24
24
|
opts.worktree && opts.checkout
|
|
25
|
-
? `You work in your own checkout (${opts.checkout}), a copy of the parent's changes as they were when you started. Scratch folder: ${opts.scratch}.`
|
|
25
|
+
? `You work in your own checkout (${opts.checkout}), a copy of the parent's changes as they were when you started. Scratch folder: ${opts.scratch}. Both are kept after you return.`
|
|
26
26
|
: `You work in the parent's checkout: read freely, but don't change it. Scratch folder: ${opts.scratch} ($AOS_SCRATCH).`,
|
|
27
27
|
"Finish with `aos return`.",
|
|
28
28
|
];
|
package/lib/fork/extras.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** What a chat worker adds for fork parents and children. */
|
|
2
2
|
|
|
3
|
+
import { isStandingRole } from "../leads/roles";
|
|
3
4
|
import os from "os";
|
|
4
5
|
import type { Session } from "../db";
|
|
5
6
|
import type { ToolGuard } from "../chat/driver";
|
|
@@ -7,7 +8,7 @@ import { CHILD_BRIEF, PARENT_BRIEF } from "./brief";
|
|
|
7
8
|
import { forkToolGuard } from "./guard";
|
|
8
9
|
import { getChild } from "./ledger";
|
|
9
10
|
import { forksOn } from "./options";
|
|
10
|
-
import { scratchOf, worktreeOf } from "./scratch";
|
|
11
|
+
import { forksRoot, scratchOf, worktreeOf } from "./scratch";
|
|
11
12
|
import { db } from "../db";
|
|
12
13
|
|
|
13
14
|
const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
|
|
@@ -16,7 +17,7 @@ const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
|
|
|
16
17
|
// parent, so their prompts match as far as they can).
|
|
17
18
|
export function forkBrief(session: Session): string | null {
|
|
18
19
|
if (getChild(session.id)) return CHILD_BRIEF;
|
|
19
|
-
if (!forksOn() || session.role
|
|
20
|
+
if (!forksOn() || isStandingRole(session.role) || session.view !== "chat")
|
|
20
21
|
return null;
|
|
21
22
|
return PARENT_BRIEF;
|
|
22
23
|
}
|
|
@@ -46,6 +47,7 @@ export function childExtras(
|
|
|
46
47
|
parent?.working_directory ?? session.working_directory
|
|
47
48
|
),
|
|
48
49
|
scratch,
|
|
50
|
+
forksRoot: forksRoot(),
|
|
49
51
|
ownCheckout: child.worktree ? worktreeOf(session.id) : null,
|
|
50
52
|
cwd: expand(session.working_directory),
|
|
51
53
|
}),
|
package/lib/fork/guard.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface ForkGuardContext {
|
|
|
27
27
|
parentCheckout: string;
|
|
28
28
|
// The child's scratch folder: free to write.
|
|
29
29
|
scratch: string;
|
|
30
|
+
// Where every fork's folders live; the others' are never written.
|
|
31
|
+
forksRoot: string;
|
|
30
32
|
// Its own throwaway checkout, when it has one: free to write.
|
|
31
33
|
ownCheckout: string | null;
|
|
32
34
|
// Where the child runs; relative paths start here.
|
|
@@ -39,7 +41,7 @@ const WHERE = (c: ForkGuardContext) =>
|
|
|
39
41
|
: `put temp work in ${c.scratch} (AOS_SCRATCH), or ask for \`aos fork --worktree\` to edit code`;
|
|
40
42
|
|
|
41
43
|
const noWrite = (c: ForkGuardContext, what: string) =>
|
|
42
|
-
`Refused: ${what} would change the parent's checkout, which
|
|
44
|
+
`Refused: ${what} would change the parent's checkout or another fork's folder, which they are working in. ${WHERE(c)}.`;
|
|
43
45
|
|
|
44
46
|
// Commands whose every path argument is something they change.
|
|
45
47
|
const MUTATORS = new Set([
|
|
@@ -134,7 +136,7 @@ async function protectedPath(
|
|
|
134
136
|
c.ownCheckout ? physicalPath(c.ownCheckout) : Promise.resolve(null),
|
|
135
137
|
]);
|
|
136
138
|
if (within(r, scratch) || (own && within(r, own))) return false;
|
|
137
|
-
return within(r, parent);
|
|
139
|
+
return within(r, parent) || within(r, await physicalPath(c.forksRoot));
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
// A file tool's refusal, or null.
|
package/lib/fork/scratch.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A fork child's own folders under the data root: a scratch folder for temp
|
|
3
|
-
* work (scripts, repros, notes), and, when it was asked for,
|
|
4
|
-
* checkout.
|
|
5
|
-
*
|
|
3
|
+
* work (scripts, repros, notes), and, when it was asked for, its own
|
|
4
|
+
* checkout. Settling a child removes nothing: they are kept while its
|
|
5
|
+
* session is open, and tidied up after it closes only when nothing in them
|
|
6
|
+
* would be lost. Deleting the child's session is the one thing that removes
|
|
7
|
+
* them whatever they hold.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import fs from "fs";
|
|
9
11
|
import path from "path";
|
|
10
12
|
import { agentosHome } from "../agentos-home";
|
|
11
13
|
import { git } from "../git-exec";
|
|
14
|
+
import { describeChanges } from "../worktree-placed";
|
|
12
15
|
|
|
13
16
|
export const forksRoot = () => agentosHome("forks");
|
|
14
17
|
export const forkHome = (childId: string) => agentosHome("forks", childId);
|
|
@@ -17,6 +20,89 @@ export const scratchOf = (childId: string) =>
|
|
|
17
20
|
export const worktreeOf = (childId: string) =>
|
|
18
21
|
path.join(forkHome(childId), "wt");
|
|
19
22
|
|
|
23
|
+
// The tree the checkout started from (the parent's changes and what setup
|
|
24
|
+
// placed included), so what the child changed can be told from it.
|
|
25
|
+
export const baseOf = (childId: string) => path.join(forkHome(childId), "base");
|
|
26
|
+
|
|
27
|
+
const MAX_LISTED = 50;
|
|
28
|
+
|
|
29
|
+
// A tree of the checkout as it is now, taken through a temporary index so
|
|
30
|
+
// the child's own index is left alone. Ignored files aren't in it.
|
|
31
|
+
export async function snapshotTree(
|
|
32
|
+
wt: string,
|
|
33
|
+
childId: string
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
const index = path.join(forkHome(childId), "index.tmp");
|
|
36
|
+
const env = { GIT_INDEX_FILE: index };
|
|
37
|
+
try {
|
|
38
|
+
await git(wt, ["read-tree", "HEAD"], { env });
|
|
39
|
+
await git(wt, ["add", "-A", "--", ".", ":(exclude)node_modules"], { env });
|
|
40
|
+
return (await git(wt, ["write-tree"], { env })).trim();
|
|
41
|
+
} finally {
|
|
42
|
+
await fs.promises.rm(index, { force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function filesUnder(dir: string, rel = ""): Promise<string[]> {
|
|
47
|
+
const out: string[] = [];
|
|
48
|
+
const entries = await fs.promises
|
|
49
|
+
.readdir(path.join(dir, rel), { withFileTypes: true })
|
|
50
|
+
.catch(() => []);
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
if (out.length >= MAX_LISTED) break;
|
|
53
|
+
const r = path.join(rel, e.name);
|
|
54
|
+
if (e.isDirectory()) out.push(...(await filesUnder(dir, r)));
|
|
55
|
+
else out.push(r);
|
|
56
|
+
}
|
|
57
|
+
return out.slice(0, MAX_LISTED);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// What removing the child's folders would lose: files in its scratch, and
|
|
61
|
+
// changes in its checkout since it started. Null when that can't be told.
|
|
62
|
+
export async function forkLosses(childId: string): Promise<string[] | null> {
|
|
63
|
+
const lost = (await filesUnder(scratchOf(childId))).map(
|
|
64
|
+
(f) => `scratch/${f}`
|
|
65
|
+
);
|
|
66
|
+
const wt = worktreeOf(childId);
|
|
67
|
+
if (fs.existsSync(wt)) {
|
|
68
|
+
try {
|
|
69
|
+
const base = (
|
|
70
|
+
await fs.promises.readFile(baseOf(childId), "utf8").catch(() => "")
|
|
71
|
+
).trim();
|
|
72
|
+
if (base) {
|
|
73
|
+
const now = await snapshotTree(wt, childId);
|
|
74
|
+
const names = await git(wt, ["diff", "--name-only", base, now]);
|
|
75
|
+
lost.push(...names.split("\n").filter(Boolean));
|
|
76
|
+
} else {
|
|
77
|
+
const st = await git(wt, ["status", "--porcelain"]);
|
|
78
|
+
lost.push(
|
|
79
|
+
...st
|
|
80
|
+
.split("\n")
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.map((l) => l.slice(3))
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return lost;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Removes the child's folders only when nothing in them would be lost.
|
|
93
|
+
export async function tidyFork(childId: string): Promise<boolean> {
|
|
94
|
+
if (!fs.existsSync(forkHome(childId))) return true;
|
|
95
|
+
const lost = await forkLosses(childId);
|
|
96
|
+
if (lost?.length === 0) {
|
|
97
|
+
await removeFork(childId);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
console.log(
|
|
101
|
+
`[forks] kept ${childId}: ${lost ? describeChanges(lost) : "it couldn't be checked"}`
|
|
102
|
+
);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
20
106
|
// Made on demand, so a settled child that is talked to again has one.
|
|
21
107
|
export async function ensureScratch(childId: string): Promise<string> {
|
|
22
108
|
const dir = scratchOf(childId);
|
|
@@ -50,18 +136,22 @@ export async function removeFork(childId: string): Promise<void> {
|
|
|
50
136
|
await fs.promises.rm(home, { recursive: true, force: true, maxRetries: 3 });
|
|
51
137
|
}
|
|
52
138
|
|
|
53
|
-
// Folders of children
|
|
54
|
-
//
|
|
139
|
+
// Folders of children whose session is closed or gone, tidied the same safe
|
|
140
|
+
// way: what a crash left, and children closed after they settled. `keep`
|
|
141
|
+
// says whose folders are still in use. Patches beside the folders stay.
|
|
55
142
|
export async function sweepForks(
|
|
56
143
|
keep: (childId: string) => boolean
|
|
57
|
-
): Promise<number> {
|
|
144
|
+
): Promise<{ removed: number; kept: number }> {
|
|
58
145
|
const root = forksRoot();
|
|
59
|
-
const
|
|
146
|
+
const entries = await fs.promises
|
|
147
|
+
.readdir(root, { withFileTypes: true })
|
|
148
|
+
.catch(() => []);
|
|
60
149
|
let removed = 0;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
removed++;
|
|
150
|
+
let kept = 0;
|
|
151
|
+
for (const e of entries) {
|
|
152
|
+
if (!e.isDirectory() || keep(e.name)) continue;
|
|
153
|
+
if (await tidyFork(e.name)) removed++;
|
|
154
|
+
else kept++;
|
|
65
155
|
}
|
|
66
|
-
return removed;
|
|
156
|
+
return { removed, kept };
|
|
67
157
|
}
|
package/lib/fork/settle.ts
CHANGED
|
@@ -26,7 +26,15 @@ import {
|
|
|
26
26
|
markDeliveredIds,
|
|
27
27
|
} from "./ledger";
|
|
28
28
|
import { FORK_LIMITS } from "./options";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
baseOf,
|
|
31
|
+
forkLosses,
|
|
32
|
+
forksRoot,
|
|
33
|
+
scratchOf,
|
|
34
|
+
snapshotTree,
|
|
35
|
+
tidyFork,
|
|
36
|
+
worktreeOf,
|
|
37
|
+
} from "./scratch";
|
|
30
38
|
import type { ChildOutcome, ChildRow, ResultKind } from "./types";
|
|
31
39
|
|
|
32
40
|
const sessionOf = (id: string) =>
|
|
@@ -53,14 +61,17 @@ function tokensOf(childId: string) {
|
|
|
53
61
|
export const patchPath = (childId: string) =>
|
|
54
62
|
path.join(forksRoot(), `${childId}.patch`);
|
|
55
63
|
|
|
56
|
-
// What a child changed in its own checkout
|
|
57
|
-
//
|
|
64
|
+
// What a child changed in its own checkout (not what the parent had when it
|
|
65
|
+
// started), kept as a patch the parent can apply.
|
|
58
66
|
async function keepPatch(childId: string): Promise<string | null> {
|
|
59
67
|
const wt = worktreeOf(childId);
|
|
60
68
|
if (!fs.existsSync(wt)) return null;
|
|
61
69
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
const base = (
|
|
71
|
+
await fs.promises.readFile(baseOf(childId), "utf8").catch(() => "")
|
|
72
|
+
).trim();
|
|
73
|
+
const now = await snapshotTree(wt, childId);
|
|
74
|
+
const diff = await git(wt, ["diff", base || "HEAD", now, "--binary"], {
|
|
64
75
|
maxBuffer: 64 * 1024 * 1024,
|
|
65
76
|
});
|
|
66
77
|
if (!diff.trim()) return null;
|
|
@@ -89,12 +100,19 @@ export async function settleAndDeliver(
|
|
|
89
100
|
const row = getChild(childId);
|
|
90
101
|
if (!row || row.state === "settled") return false;
|
|
91
102
|
let result = s.result ?? null;
|
|
92
|
-
if (row.worktree
|
|
103
|
+
if (row.worktree) {
|
|
93
104
|
const patch = await keepPatch(childId);
|
|
94
105
|
if (patch)
|
|
95
106
|
result =
|
|
96
107
|
`${result ?? ""}\n\nIts changes are in ${patch} (apply with \`git apply ${patch}\`).`.trim();
|
|
97
108
|
}
|
|
109
|
+
if (
|
|
110
|
+
(await forkLosses(childId).catch(() => null))?.some((f) =>
|
|
111
|
+
f.startsWith("scratch/")
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
result =
|
|
115
|
+
`${result ?? ""}\n\nIts scratch files are in ${scratchOf(childId)}.`.trim();
|
|
98
116
|
const changed = settleChild(childId, {
|
|
99
117
|
...s,
|
|
100
118
|
result,
|
|
@@ -103,7 +121,11 @@ export async function settleAndDeliver(
|
|
|
103
121
|
if (!changed) return false;
|
|
104
122
|
// Cancelled by its parent or the user: they know, so nothing is sent.
|
|
105
123
|
if (s.outcome === "cancelled") markDeliveredIds([childId]);
|
|
106
|
-
|
|
124
|
+
// Its folders stay while its session is open; a closed one's go only
|
|
125
|
+
// when nothing in them would be lost.
|
|
126
|
+
const session = sessionOf(childId);
|
|
127
|
+
if (!session || session.archived_at)
|
|
128
|
+
await tidyFork(childId).catch(() => undefined);
|
|
107
129
|
const { admitQueued } = await import("./start");
|
|
108
130
|
await admitQueued(row.parent_session_id).catch(() => undefined);
|
|
109
131
|
const { kickForks } = await import("./tick");
|
|
@@ -175,6 +197,8 @@ const lastReply = (childId: string): string => {
|
|
|
175
197
|
export async function lookAtChild(row: ChildRow, now: number): Promise<void> {
|
|
176
198
|
if (row.state === "queued" || row.state === "settled") return;
|
|
177
199
|
const session = sessionOf(row.child_session_id);
|
|
200
|
+
// Its checkout is still being made: no session row yet.
|
|
201
|
+
if (!session && row.state === "starting") return;
|
|
178
202
|
if (!session || session.archived_at) {
|
|
179
203
|
await settleAndDeliver(row.child_session_id, {
|
|
180
204
|
outcome: "failed",
|
package/lib/fork/start.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* holds a slot never waits on its own children.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { isStandingRole, RoleLabel } from "../leads/roles";
|
|
8
9
|
import os from "os";
|
|
9
10
|
import { randomUUID } from "crypto";
|
|
10
11
|
import { db, queries, type Session } from "../db";
|
|
@@ -34,7 +35,7 @@ import {
|
|
|
34
35
|
setState,
|
|
35
36
|
} from "./ledger";
|
|
36
37
|
import { FORK_LIMITS, FORKS_OFF, forksOn, type DeliveryMode } from "./options";
|
|
37
|
-
import { ensureScratch,
|
|
38
|
+
import { ensureScratch, tidyFork } from "./scratch";
|
|
38
39
|
import { parentSnapshot } from "./snapshot";
|
|
39
40
|
import { createForkWorktree } from "./worktree";
|
|
40
41
|
import type { ChildRow } from "./types";
|
|
@@ -66,8 +67,8 @@ export function refusal(parent: Session | undefined): string | null {
|
|
|
66
67
|
if (!forksOn()) return FORKS_OFF;
|
|
67
68
|
if (!parent) return "Unknown session";
|
|
68
69
|
if (getChild(parent.id)) return "A fork child can't fork";
|
|
69
|
-
if (parent.role
|
|
70
|
-
return
|
|
70
|
+
if (isStandingRole(parent.role))
|
|
71
|
+
return `${RoleLabel(parent.role)} doesn't fork: start tasks or sessions instead`;
|
|
71
72
|
if (parent.archived_at) return "That session is archived";
|
|
72
73
|
if (parent.view !== "chat") return "Forks work from a chat session";
|
|
73
74
|
if (parent.host_id && parent.host_id !== "local")
|
|
@@ -266,7 +267,7 @@ async function startChild(childId: string): Promise<void> {
|
|
|
266
267
|
} catch (error) {
|
|
267
268
|
const why = error instanceof Error ? error.message : String(error);
|
|
268
269
|
const { settleAndDeliver } = await import("./settle");
|
|
269
|
-
await
|
|
270
|
+
await tidyFork(childId).catch(() => undefined);
|
|
270
271
|
await settleAndDeliver(childId, {
|
|
271
272
|
outcome: "failed",
|
|
272
273
|
summary: `couldn't start: ${why.split("\n")[0]}`,
|
package/lib/fork/tick.ts
CHANGED
|
@@ -7,12 +7,30 @@ import { deliverDue } from "./deliver";
|
|
|
7
7
|
import { lookAtChild } from "./settle";
|
|
8
8
|
import { tickForkWaits } from "./waits";
|
|
9
9
|
import { forksOn } from "./options";
|
|
10
|
+
import { sweepForks } from "./scratch";
|
|
10
11
|
import type { ChildRow } from "./types";
|
|
11
12
|
|
|
12
13
|
const EVERY_MS = 5000;
|
|
13
14
|
let running = false;
|
|
14
15
|
let timer: NodeJS.Timeout | null = null;
|
|
15
16
|
let kicked = false;
|
|
17
|
+
const SWEEP_EVERY_MS = 10 * 60_000;
|
|
18
|
+
let sweptAt = 0;
|
|
19
|
+
|
|
20
|
+
// Folders of children whose session closed after they settled, or a crash.
|
|
21
|
+
async function sweepClosed(now: number): Promise<void> {
|
|
22
|
+
if (now - sweptAt < SWEEP_EVERY_MS) return;
|
|
23
|
+
sweptAt = now;
|
|
24
|
+
const live = db.prepare(
|
|
25
|
+
`SELECT 1 FROM session_children c
|
|
26
|
+
LEFT JOIN sessions s ON s.id = c.child_session_id
|
|
27
|
+
WHERE c.child_session_id = ?
|
|
28
|
+
AND (c.state != 'settled' OR (s.id IS NOT NULL AND s.archived_at IS NULL))`
|
|
29
|
+
);
|
|
30
|
+
await sweepForks((id) => !!live.get(id)).catch((error) =>
|
|
31
|
+
console.error("[forks] sweep:", error)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
16
34
|
|
|
17
35
|
export async function forksTick(now = Date.now()): Promise<void> {
|
|
18
36
|
if (running || !forksOn()) return;
|
|
@@ -33,6 +51,7 @@ export async function forksTick(now = Date.now()): Promise<void> {
|
|
|
33
51
|
await admitQueued(parent);
|
|
34
52
|
await tickForkWaits(now);
|
|
35
53
|
await deliverDue(now);
|
|
54
|
+
await sweepClosed(now);
|
|
36
55
|
} finally {
|
|
37
56
|
running = false;
|
|
38
57
|
}
|
package/lib/fork/worktree.ts
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
* A throwaway checkout for a fork child that has to edit code to find its
|
|
3
3
|
* answer (`aos fork --worktree`): detached at the parent's HEAD, with the
|
|
4
4
|
* parent's uncommitted changes applied on top, so it sees what the parent
|
|
5
|
-
* sees. No branch is made and nothing from it is merged; it is
|
|
6
|
-
* the child
|
|
5
|
+
* sees. No branch is made and nothing from it is merged; it is kept until
|
|
6
|
+
* the child's session is closed with nothing changed in it.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import path from "path";
|
|
11
11
|
import { git } from "../git-exec";
|
|
12
12
|
import { setupWorktree } from "../env-setup";
|
|
13
|
-
import { forkHome, worktreeOf } from "./scratch";
|
|
13
|
+
import { baseOf, forkHome, snapshotTree, worktreeOf } from "./scratch";
|
|
14
14
|
|
|
15
15
|
const MAX_UNTRACKED = 200;
|
|
16
16
|
const MAX_UNTRACKED_BYTES = 5_000_000;
|
|
@@ -70,6 +70,10 @@ export async function createForkWorktree(opts: {
|
|
|
70
70
|
throw new Error(
|
|
71
71
|
setup.steps.find((s) => !s.success)?.error ?? "worktree setup failed"
|
|
72
72
|
);
|
|
73
|
+
await fs.promises.writeFile(
|
|
74
|
+
baseOf(opts.childId),
|
|
75
|
+
await snapshotTree(wt, opts.childId)
|
|
76
|
+
);
|
|
73
77
|
return {
|
|
74
78
|
cwd: path.join(wt, path.relative(top, cwd)),
|
|
75
79
|
root: wt,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { db } from "@/lib/db";
|
|
2
|
+
import type { SessionRole } from "@/lib/db/types";
|
|
2
3
|
import { MIN } from "../types";
|
|
3
4
|
import { fixFor } from "./fixText";
|
|
4
5
|
import { classById } from "./classes";
|
|
@@ -10,11 +11,11 @@ export const STALE_MS = 120 * MIN;
|
|
|
10
11
|
export interface NoteDeps {
|
|
11
12
|
send: (sessionId: string, body: string) => Promise<void>;
|
|
12
13
|
// Waiting on a person: an approval or a question.
|
|
13
|
-
blocked: (sessionId: string, role:
|
|
14
|
+
blocked: (sessionId: string, role: SessionRole | null) => boolean;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
interface Target {
|
|
17
|
-
role:
|
|
18
|
+
role: SessionRole | null;
|
|
18
19
|
archived_at: string | null;
|
|
19
20
|
task_status: string | null;
|
|
20
21
|
pause_state: string | null;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* (the menu shows the same reasons: lib/hosts/remote-menu.ts).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { isPeerStanding } from "../leads/roles";
|
|
8
9
|
import { db, type Session } from "../db";
|
|
9
10
|
import { stopChat } from "../chat/runner";
|
|
10
11
|
import { deleteItems } from "../chat/store";
|
|
@@ -151,7 +152,7 @@ export async function doneOnPeer(
|
|
|
151
152
|
throw new Error(
|
|
152
153
|
`${session.name} is a task on ${link.hostName}: sign it off or drop it in Tasks there.`
|
|
153
154
|
);
|
|
154
|
-
if (there.role)
|
|
155
|
+
if (isPeerStanding(there.role))
|
|
155
156
|
throw new Error(`${session.name} isn't marked done on ${link.hostName}.`);
|
|
156
157
|
const { outcome } = await hostApi<{
|
|
157
158
|
outcome?: { text?: unknown; worktree?: { action?: unknown } };
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* used here are kept, checked and cleaned.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { isPeerStanding } from "../leads/roles";
|
|
8
9
|
import type { TmuxSessionInfo } from "../status-detector";
|
|
9
10
|
import { isValidTmuxName } from "./attach";
|
|
10
11
|
import { syncPeerMirrors } from "./peer-sync";
|
|
@@ -113,7 +114,8 @@ export function toPeerSession(
|
|
|
113
114
|
if (raw.host_id && raw.host_id !== "local") return null;
|
|
114
115
|
// Its orchestrator and its tasks belong to its own workspace and gates:
|
|
115
116
|
// they're not mirrored as plain sessions here.
|
|
116
|
-
if (raw.role || raw.task_prompt || raw.task_status)
|
|
117
|
+
if (isPeerStanding(raw.role) || raw.task_prompt || raw.task_status)
|
|
118
|
+
return null;
|
|
117
119
|
const tmuxName = typeof raw.tmux_name === "string" ? raw.tmux_name : "";
|
|
118
120
|
const view = raw.view === "chat" ? "chat" : "terminal";
|
|
119
121
|
// A terminal is opened by its tmux name; any name it gives must be one.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
+
import type { Session } from "@/lib/db/types";
|
|
3
|
+
|
|
4
|
+
// The standing session whose tool call is running: what it sends, starts and
|
|
5
|
+
// notes is its own, not the orchestrator's. Outside a tool call (an event, a
|
|
6
|
+
// route, a timer) there is none, and everything stays the orchestrator's.
|
|
7
|
+
const acting = new AsyncLocalStorage<Session>();
|
|
8
|
+
|
|
9
|
+
export const asActor = <T>(session: Session, fn: () => T): T =>
|
|
10
|
+
acting.run(session, fn);
|
|
11
|
+
|
|
12
|
+
// The caller in this workspace, if a standing session's call is running.
|
|
13
|
+
export function actorIn(workspaceId: string): Session | undefined {
|
|
14
|
+
const s = acting.getStore();
|
|
15
|
+
return s && s.workspace_id === workspaceId ? s : undefined;
|
|
16
|
+
}
|