@saadnvd1/agent-os 0.3.476 → 0.3.479
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/app/api/sessions/[id]/route.test.ts +66 -0
- package/app/api/sessions/[id]/route.ts +10 -7
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/tmux/kill-all/route.ts +2 -1
- package/components/SessionList/OrchestratorPinRow.tsx +12 -6
- package/components/SessionList/QuickChangeRow.tsx +58 -0
- package/components/SessionList/SessionRow.tsx +4 -0
- package/components/Tasks/TaskRow.tsx +2 -17
- package/components/Tasks/task-state-labels.ts +18 -0
- package/lib/away/sweep.ts +2 -0
- package/lib/chat/hold.ts +6 -2
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/worker/client.ts +2 -3
- package/lib/db/migrations.ts +17 -0
- package/lib/db/types.ts +3 -0
- package/lib/done/index.ts +2 -8
- package/lib/done/worktree.ts +9 -2
- package/lib/orchestration.ts +4 -5
- package/lib/orchestrator/act.ts +3 -6
- package/lib/orchestrator/ask-approvals.ts +11 -0
- package/lib/orchestrator/brief.ts +7 -4
- package/lib/orchestrator/conditions.ts +2 -1
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +13 -0
- package/lib/orchestrator/git-guard.ts +201 -0
- package/lib/orchestrator/pr-wake.ts +9 -1
- package/lib/orchestrator/quick-store.ts +18 -0
- package/lib/orchestrator/quick.ts +307 -0
- package/lib/orchestrator/rebase.ts +18 -2
- package/lib/orchestrator/repo.ts +78 -13
- package/lib/orchestrator/review.ts +21 -0
- package/lib/orchestrator/serve.ts +17 -0
- package/lib/orchestrator/signoff.ts +37 -6
- package/lib/orchestrator/task-state.ts +2 -0
- package/lib/orchestrator/tool-names.ts +9 -2
- package/lib/orchestrator/tool-schemas.ts +26 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/orchestrator/write-guard.ts +80 -3
- package/lib/sessions/end.ts +15 -0
- package/lib/sidebar/shelves.ts +19 -1
- package/lib/stacks/guard.ts +11 -0
- package/lib/tasks/finish.ts +9 -8
- package/lib/tasks/gh.ts +3 -1
- package/lib/tasks/index.ts +18 -6
- package/lib/tasks/move-guard.ts +2 -0
- package/lib/tasks/move.ts +2 -6
- package/lib/tasks/pr-poll.ts +1 -1
- package/lib/tasks/queue.ts +3 -2
- package/lib/tasks/session.ts +63 -2
- package/lib/tasks/start.ts +2 -4
- package/lib/tasks/state.ts +3 -0
- package/lib/tmux/end.ts +33 -0
- package/lib/tmux/orphans.ts +146 -0
- package/package.json +1 -1
- package/server.ts +9 -0
package/lib/orchestrator/repo.ts
CHANGED
|
@@ -7,8 +7,16 @@ import os from "os";
|
|
|
7
7
|
import path from "path";
|
|
8
8
|
import type { Session } from "../db";
|
|
9
9
|
import { SKILL_DIRS } from "../agents/skill-dirs";
|
|
10
|
+
import { getDefaultBranch } from "../git";
|
|
10
11
|
import { getProject } from "../projects";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
git,
|
|
14
|
+
gitFetch,
|
|
15
|
+
gitOk,
|
|
16
|
+
isGitError,
|
|
17
|
+
worktreeAdd,
|
|
18
|
+
worktreeRemove,
|
|
19
|
+
} from "../git-exec";
|
|
12
20
|
import { expandHome } from "../tasks/session";
|
|
13
21
|
|
|
14
22
|
export function repoOf(task: Session): string {
|
|
@@ -17,22 +25,79 @@ export function repoOf(task: Session): string {
|
|
|
17
25
|
return expandHome(project.working_directory);
|
|
18
26
|
}
|
|
19
27
|
|
|
20
|
-
// The task's base on origin, fetched along with its head.
|
|
28
|
+
// The task's base on origin, fetched along with its head. The base is the
|
|
29
|
+
// PR's own (prFor records GitHub's baseRefName on the task). A base gone
|
|
30
|
+
// from origin (a stacked parent's branch, deleted) falls back to the
|
|
31
|
+
// project's default branch, and so does one that can't be found at all, so
|
|
32
|
+
// a diff never names a missing revision.
|
|
21
33
|
export async function fetchRefs(
|
|
22
34
|
repo: string,
|
|
23
35
|
task: Pick<Session, "base_branch" | "branch_name">
|
|
24
36
|
): Promise<string> {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
const head = task.branch_name ? [task.branch_name] : [];
|
|
38
|
+
const recorded = task.base_branch || null;
|
|
39
|
+
if (recorded) {
|
|
40
|
+
const fetched = await fetchBranches(repo, [recorded, ...head]);
|
|
41
|
+
// A base gone on origin fails the fetch, so the head goes on its own.
|
|
42
|
+
if (fetched === "gone" && head.length)
|
|
43
|
+
requireFetched(await fetchBranches(repo, head));
|
|
44
|
+
requireFetched(fetched);
|
|
45
|
+
if (fetched !== "gone") {
|
|
46
|
+
const found = await existingRef(repo, recorded);
|
|
47
|
+
if (found) return found;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const fallback = await getDefaultBranch(repo);
|
|
51
|
+
if (fallback !== recorded) {
|
|
52
|
+
requireFetched(
|
|
53
|
+
await fetchBranches(repo, recorded ? [fallback] : [fallback, ...head])
|
|
54
|
+
);
|
|
55
|
+
const found = await existingRef(repo, fallback);
|
|
56
|
+
if (found) return found;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Neither the base ${recorded ?? "(none recorded)"} nor the default branch ${fallback} can be found`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// An unreachable origin leaves the tracking refs as they were, and a diff
|
|
64
|
+
// against a stale base can hide or invent changes, so the gates stop.
|
|
65
|
+
function requireFetched(result: "ok" | "gone" | "failed"): void {
|
|
66
|
+
if (result === "failed")
|
|
67
|
+
throw new Error("Couldn't fetch from origin, so the base can't be trusted");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// "gone" when origin answered that the first branch isn't there; any other
|
|
71
|
+
// failure is "failed" (origin unreachable).
|
|
72
|
+
async function fetchBranches(
|
|
73
|
+
repo: string,
|
|
74
|
+
branches: string[]
|
|
75
|
+
): Promise<"ok" | "gone" | "failed"> {
|
|
76
|
+
try {
|
|
77
|
+
// In English whatever the locale: the message is read below.
|
|
78
|
+
await gitFetch(repo, ["--quiet", "--", "origin", ...branches], {
|
|
79
|
+
env: { LC_ALL: "C" },
|
|
80
|
+
});
|
|
81
|
+
return "ok";
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const stderr = isGitError(error) ? error.stderr : "";
|
|
84
|
+
return stderr.includes(`couldn't find remote ref ${branches[0]}`)
|
|
85
|
+
? "gone"
|
|
86
|
+
: "failed";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// origin/<branch> when it's there, else a local <branch>.
|
|
91
|
+
async function existingRef(
|
|
92
|
+
repo: string,
|
|
93
|
+
branch: string
|
|
94
|
+
): Promise<string | null> {
|
|
95
|
+
for (const ref of [`origin/${branch}`, branch])
|
|
96
|
+
if (
|
|
97
|
+
await gitOk(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])
|
|
98
|
+
)
|
|
99
|
+
return ref;
|
|
100
|
+
return null;
|
|
36
101
|
}
|
|
37
102
|
|
|
38
103
|
// When the commit was made, in seconds.
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
checkRunTotals,
|
|
44
44
|
describeCheckRuns,
|
|
45
45
|
} from "../usage/check-runs";
|
|
46
|
+
import { noteQuickReview } from "./quick";
|
|
46
47
|
import { workspaceTask } from "./targets";
|
|
47
48
|
import { untrusted } from "./untrusted";
|
|
48
49
|
|
|
@@ -65,6 +66,13 @@ export function describeReview(row: CheckRow): string {
|
|
|
65
66
|
// What a review reads: a task's PR, or a PR no task owns (external-pr.ts).
|
|
66
67
|
// `id` keys its stored checks; `task` is set only for a task, whose card
|
|
67
68
|
// gets a scope check.
|
|
69
|
+
const baseBranchOf = (id: string) =>
|
|
70
|
+
(
|
|
71
|
+
db.prepare(`SELECT base_branch FROM sessions WHERE id = ?`).get(id) as
|
|
72
|
+
| { base_branch: string | null }
|
|
73
|
+
| undefined
|
|
74
|
+
)?.base_branch ?? null;
|
|
75
|
+
|
|
68
76
|
export interface ReviewTarget {
|
|
69
77
|
id: string;
|
|
70
78
|
name: string;
|
|
@@ -85,6 +93,7 @@ async function reviewJob(
|
|
|
85
93
|
): Promise<CheckRow> {
|
|
86
94
|
const { pr, repo, task } = target;
|
|
87
95
|
const row = { workspaceId, sessionId: target.id, sha };
|
|
96
|
+
const judged = target.refs.base_branch;
|
|
88
97
|
const base = await fetchRefs(repo, target.refs);
|
|
89
98
|
const diff = await fullDiff(repo, base, sha);
|
|
90
99
|
const parts =
|
|
@@ -160,6 +169,16 @@ async function reviewJob(
|
|
|
160
169
|
});
|
|
161
170
|
verdicts.push(toVerdict(answer));
|
|
162
171
|
}
|
|
172
|
+
// A retarget while it ran (prFor records it): this verdict read the
|
|
173
|
+
// diff against the old base, so it covers nothing.
|
|
174
|
+
const now = task ? baseBranchOf(task.id) : judged;
|
|
175
|
+
if (now !== judged)
|
|
176
|
+
return putCheck({
|
|
177
|
+
...row,
|
|
178
|
+
kind: "review",
|
|
179
|
+
status: "error",
|
|
180
|
+
detail: `the PR's base moved from ${judged} to ${now} while it was reviewed; review it again`,
|
|
181
|
+
});
|
|
163
182
|
const done = putCheck({
|
|
164
183
|
...row,
|
|
165
184
|
kind: "review",
|
|
@@ -254,6 +273,8 @@ export async function reviewAt(
|
|
|
254
273
|
})
|
|
255
274
|
)
|
|
256
275
|
.then((done) => {
|
|
276
|
+
if (done.status === "pass" && target.task)
|
|
277
|
+
noteQuickReview(target.task, sha);
|
|
257
278
|
const said =
|
|
258
279
|
done.status === "pass"
|
|
259
280
|
? "passed"
|
|
@@ -26,6 +26,8 @@ import { readSession } from "./read";
|
|
|
26
26
|
import { reviewTarget, signOffTarget } from "./external-gates";
|
|
27
27
|
import { land } from "./signoff";
|
|
28
28
|
import { arm, disarm } from "./armed";
|
|
29
|
+
import { quickChange, quickPR } from "./quick";
|
|
30
|
+
import { review } from "./review";
|
|
29
31
|
import { parseArgs as p, type ToolName } from "./tool-schemas";
|
|
30
32
|
|
|
31
33
|
export { isToolName, TOOLS, type ToolName } from "./tool-schemas";
|
|
@@ -37,6 +39,8 @@ const ACTING = new Set<ToolName>([
|
|
|
37
39
|
"start_task",
|
|
38
40
|
"set_task_model",
|
|
39
41
|
"start_session",
|
|
42
|
+
"quick_change",
|
|
43
|
+
"quick_pr",
|
|
40
44
|
"stack",
|
|
41
45
|
"land",
|
|
42
46
|
"drop",
|
|
@@ -119,6 +123,19 @@ export async function runTool(
|
|
|
119
123
|
view: a.view,
|
|
120
124
|
});
|
|
121
125
|
}
|
|
126
|
+
case "quick_change": {
|
|
127
|
+
const a = p(tool, raw);
|
|
128
|
+
return quickChange(w, a.project, a.name);
|
|
129
|
+
}
|
|
130
|
+
case "quick_pr": {
|
|
131
|
+
const a = p(tool, raw);
|
|
132
|
+
const opened = await quickPR(w, a.change, a.title, a.body);
|
|
133
|
+
// Its code-review gate is this review: it starts at once.
|
|
134
|
+
const reviewing = await review(w, opened.id).catch(
|
|
135
|
+
(e: Error) => `Its review couldn't start (${e.message}): call review.`
|
|
136
|
+
);
|
|
137
|
+
return `${opened.text}\n${reviewing}`;
|
|
138
|
+
}
|
|
122
139
|
case "stack": {
|
|
123
140
|
const a = p(tool, raw);
|
|
124
141
|
return stack(w, a.target, a.plan_only);
|
|
@@ -15,6 +15,7 @@ import { getStack, landInBackground } from "../stacks";
|
|
|
15
15
|
import { LAND_DEFAULTS } from "../stacks/land";
|
|
16
16
|
import { db, stackQueries, type Session } from "../db";
|
|
17
17
|
import { getCheck } from "./checks";
|
|
18
|
+
import { isQuick, quickCodeReview } from "./quick";
|
|
18
19
|
import {
|
|
19
20
|
addedLines,
|
|
20
21
|
changedFiles,
|
|
@@ -71,8 +72,33 @@ export async function judge(
|
|
|
71
72
|
const pr = await prFor(task, true);
|
|
72
73
|
if (!pr || pr.state !== "OPEN" || !pr.head)
|
|
73
74
|
return { ok: false, wait: false, text: `${task.name} has no open PR` };
|
|
75
|
+
// prFor records a retarget to the default branch or the stack parent's
|
|
76
|
+
// branch; any other base would merge somewhere nobody chose, judged
|
|
77
|
+
// against a branch the task picked.
|
|
78
|
+
if (pr.base && pr.base !== task.base_branch)
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
wait: false,
|
|
82
|
+
text: `${task.name}'s PR #${pr.number} targets ${pr.base}, not its base ${task.base_branch ?? "(none)"}: retarget it to ${task.base_branch ?? "the default branch"}`,
|
|
83
|
+
};
|
|
74
84
|
const sha = pr.head;
|
|
75
85
|
const repo = repoOf(task);
|
|
86
|
+
// A PR quick_pr opened (by the row AgentOS wrote, never the body): its
|
|
87
|
+
// code review is the independent review of this head, and until that
|
|
88
|
+
// passes it waits, before any gate is judged or counted.
|
|
89
|
+
const quick = isQuick(task)
|
|
90
|
+
? quickCodeReview(
|
|
91
|
+
sha,
|
|
92
|
+
getCheck(task.id, sha, "review"),
|
|
93
|
+
restackedOf(task)?.to === sha
|
|
94
|
+
)
|
|
95
|
+
: null;
|
|
96
|
+
if (quick?.wait)
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
wait: true,
|
|
100
|
+
text: `${task.name} (PR #${pr.number} at ${short(sha)}) can't merge yet: ${quick.wait}`,
|
|
101
|
+
};
|
|
76
102
|
return gateVerdict(
|
|
77
103
|
workspaceId,
|
|
78
104
|
{ id: task.id, name: task.name, repo, refs: task, pr, sha },
|
|
@@ -91,12 +117,17 @@ export async function judge(
|
|
|
91
117
|
fromCard: !!task.lh_card_id,
|
|
92
118
|
scope: getCheck(task.id, sha, "scope"),
|
|
93
119
|
stackRefusal: landing ? null : signOffRefusal(task.id),
|
|
94
|
-
codeReview:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
codeReview: (() => {
|
|
121
|
+
const gate = codeReviewGate({
|
|
122
|
+
head: sha,
|
|
123
|
+
attribution: pr.attribution,
|
|
124
|
+
review: (at) => getCheck(task.id, at, "review"),
|
|
125
|
+
restacked: restackedOf(task),
|
|
126
|
+
});
|
|
127
|
+
// A quick change keeps the body checks (attribution, unreadable);
|
|
128
|
+
// its wait on the review was judged above.
|
|
129
|
+
return quick ? { refusal: gate.refusal, wait: null } : gate;
|
|
130
|
+
})(),
|
|
100
131
|
}),
|
|
101
132
|
opts
|
|
102
133
|
);
|
|
@@ -17,6 +17,8 @@ import { untrusted } from "./untrusted";
|
|
|
17
17
|
export async function waitingState(
|
|
18
18
|
task: Session
|
|
19
19
|
): Promise<{ blocked: string | null; waitingOn: string | null }> {
|
|
20
|
+
// A quick change has no agent to block or ask: the orchestrator makes it.
|
|
21
|
+
if (task.quick_of) return { blocked: null, waitingOn: null };
|
|
20
22
|
// A linked machine's chat lives in that machine's store, not this one's:
|
|
21
23
|
// its own reading of the task is asked for, fresh.
|
|
22
24
|
const link = hostLink(task.host_id);
|
|
@@ -16,6 +16,8 @@ export const TOOL_NAMES = {
|
|
|
16
16
|
start_task: name("start_task"),
|
|
17
17
|
set_task_model: name("set_task_model"),
|
|
18
18
|
start_session: name("start_session"),
|
|
19
|
+
quick_change: name("quick_change"),
|
|
20
|
+
quick_pr: name("quick_pr"),
|
|
19
21
|
stack: name("stack"),
|
|
20
22
|
stack_status: name("stack_status"),
|
|
21
23
|
land: name("land"),
|
|
@@ -39,8 +41,11 @@ export const TOOL_NAMES = {
|
|
|
39
41
|
// braked; the shell keeps only `aos` commands that read, and notify, which
|
|
40
42
|
// reaches only Saad, so `aos send`, `task`, `spawn` and `stack` can't go
|
|
41
43
|
// around them. It writes files, but never in a project repository, a task
|
|
42
|
-
// worktree, AgentOS's own state or a secret file
|
|
43
|
-
//
|
|
44
|
+
// worktree, AgentOS's own state or a secret file, except inside its own
|
|
45
|
+
// quick changes' worktrees (quick.ts), where it may also run git that
|
|
46
|
+
// looks, stages or undoes, never commits with hooks or pushes: its worker
|
|
47
|
+
// checks each write and each shell command first (write-guard.ts,
|
|
48
|
+
// git-guard.ts).
|
|
44
49
|
export const ORCHESTRATOR_PERMISSIONS: {
|
|
45
50
|
permissionMode: PermissionMode;
|
|
46
51
|
allowedTools: string[];
|
|
@@ -67,6 +72,8 @@ export const ORCHESTRATOR_PERMISSIONS: {
|
|
|
67
72
|
"Bash(aos notify:*)",
|
|
68
73
|
"Bash(aos docs:*)",
|
|
69
74
|
"Bash(aos doc:*)",
|
|
75
|
+
// Only `git -C <its own quick worktree>`, refused anywhere else.
|
|
76
|
+
"Bash(git:*)",
|
|
70
77
|
],
|
|
71
78
|
disallowedTools: [],
|
|
72
79
|
};
|
|
@@ -135,6 +135,32 @@ export const TOOL_SHAPES = {
|
|
|
135
135
|
.describe("A short name, 2-6 words (default: one made from the prompt)"),
|
|
136
136
|
view,
|
|
137
137
|
},
|
|
138
|
+
quick_change: {
|
|
139
|
+
project,
|
|
140
|
+
name: z
|
|
141
|
+
.string()
|
|
142
|
+
.trim()
|
|
143
|
+
.min(1)
|
|
144
|
+
.max(80)
|
|
145
|
+
.describe("A short name for the change, 2-6 words"),
|
|
146
|
+
},
|
|
147
|
+
quick_pr: {
|
|
148
|
+
change: ref("Your quick change, by name or id"),
|
|
149
|
+
title: z
|
|
150
|
+
.string()
|
|
151
|
+
.trim()
|
|
152
|
+
.min(1)
|
|
153
|
+
.max(200)
|
|
154
|
+
.describe("The PR's title, also its commit message"),
|
|
155
|
+
body: z
|
|
156
|
+
.string()
|
|
157
|
+
.trim()
|
|
158
|
+
.min(1)
|
|
159
|
+
.max(20000)
|
|
160
|
+
.describe(
|
|
161
|
+
"What changed and why; the review judges the change against it"
|
|
162
|
+
),
|
|
163
|
+
},
|
|
138
164
|
stack: {
|
|
139
165
|
target: ref("Board or project name"),
|
|
140
166
|
plan_only: z.boolean().optional(),
|
|
@@ -66,6 +66,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
66
66
|
'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
|
|
67
67
|
start_session:
|
|
68
68
|
"Start an interactive agent session in a project with a prompt. It runs as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.",
|
|
69
|
+
quick_change:
|
|
70
|
+
"Make a small, low-risk edit yourself (a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. You may write there (Write, Edit) and run git there as `git -C <path> ...` (status, diff, add, rm, mv, restore, reset, log, show); nowhere else in a project. Anything needing real work, tests or an investigation is a task (start_task).",
|
|
71
|
+
quick_pr:
|
|
72
|
+
"Commit everything in a quick change's worktree, push its branch (never the default branch) and open its PR, or push to the PR it already has. The PR goes through the same gates as a task's; with no task agent to run /do-code-review, its code-review gate is the independent review of its exact head, which starts now.",
|
|
69
73
|
stack:
|
|
70
74
|
"Run a LumifyHub board's open cards as stacked tasks (each start braked), or with plan_only just show the plan.",
|
|
71
75
|
stack_status: "One stack's items, PRs and progress.",
|
|
@@ -21,6 +21,8 @@ import { isSecretPath } from "../secret-paths";
|
|
|
21
21
|
import { WORKTREES_DIR } from "../worktrees";
|
|
22
22
|
import { orchestratorDir } from "./home";
|
|
23
23
|
import { LIMITS_FILE } from "./usage";
|
|
24
|
+
import { GIT_LAYOUT_FILE, readCommand } from "./git-guard";
|
|
25
|
+
import { quickWorktrees } from "./quick-store";
|
|
24
26
|
|
|
25
27
|
// The tools that write a file, and the argument naming it.
|
|
26
28
|
export const WRITE_TOOLS: Record<string, string> = {
|
|
@@ -43,6 +45,9 @@ export interface WriteBounds {
|
|
|
43
45
|
home: string;
|
|
44
46
|
// Folders whose files run (~/Library, PATH, the usage brake's input...).
|
|
45
47
|
runs: string[];
|
|
48
|
+
// Its own quick changes' worktrees (quick.ts): the one place in a
|
|
49
|
+
// project it may write, everything else in `repos` still refused.
|
|
50
|
+
quick?: string[];
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
// Wherever they are, files that configure what an agent, git or a
|
|
@@ -129,6 +134,7 @@ export async function writeRefusal(
|
|
|
129
134
|
let ownDir: string[];
|
|
130
135
|
let home: string[];
|
|
131
136
|
let runs: string[];
|
|
137
|
+
let quick: string[][];
|
|
132
138
|
try {
|
|
133
139
|
// Where it lands as written, and with `..` taken by name first, as a
|
|
134
140
|
// tool that normalises the path before opening it would; and where it
|
|
@@ -151,16 +157,27 @@ export async function writeRefusal(
|
|
|
151
157
|
state = await both(bounds.state);
|
|
152
158
|
ownDir = await both(bounds.ownDir);
|
|
153
159
|
home = await both(bounds.home);
|
|
160
|
+
quick = await Promise.all((bounds.quick ?? []).map(both));
|
|
154
161
|
} catch (e) {
|
|
155
162
|
return `Couldn't resolve ${filePath}: ${(e as Error).message}.`;
|
|
156
163
|
}
|
|
157
164
|
for (const target of targets) {
|
|
158
|
-
|
|
165
|
+
// Inside one of its quick worktrees, the roots that hold that worktree
|
|
166
|
+
// (the worktrees folder, a project above it) don't count; any other
|
|
167
|
+
// root, one nested inside it included, still refuses.
|
|
168
|
+
const ownQuick = quick.find((q) => q.some((d) => within(target, d)));
|
|
169
|
+
const holdsQuick = (r: string) =>
|
|
170
|
+
!!ownQuick && ownQuick.some((d) => within(d, r));
|
|
171
|
+
const repo = repos.find((r) => within(target, r) && !holdsQuick(r));
|
|
159
172
|
if (repo)
|
|
160
|
-
return `${filePath} is inside ${repo}, a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), so its PR goes through the merge gates.`;
|
|
173
|
+
return `${filePath} is inside ${repo}, a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), or for a small edit, in a quick change's worktree (quick_change), so its PR goes through the merge gates.`;
|
|
174
|
+
if (ownQuick && GIT_LAYOUT_FILE.test(target))
|
|
175
|
+
return `${filePath} is part of a git repository's layout (HEAD, .git/, .gitattributes); git config can run programs, so orchestrators never write those.`;
|
|
161
176
|
if (AGENTOS_TEMP.test(target))
|
|
162
177
|
return `${filePath} is in a checkout AgentOS is reviewing or rebasing; orchestrators never write there.`;
|
|
163
|
-
|
|
178
|
+
// Its folder, or a quick worktree (which sits in ~/.agent-os/worktrees):
|
|
179
|
+
// AgentOS's state and the home's dot-folders hold it, but it's its own.
|
|
180
|
+
const own = ownDir.some((d) => within(target, d)) || !!ownQuick;
|
|
164
181
|
if (state.some((d) => within(target, d)) && !own)
|
|
165
182
|
return `${filePath} is in AgentOS's own state (${bounds.state}); you may write only in your folder, ${bounds.ownDir}.`;
|
|
166
183
|
if (isSecretPath(target) || isSecretPath(filePath))
|
|
@@ -219,6 +236,7 @@ export async function orchestratorWriteBounds(
|
|
|
219
236
|
...(process.env.PATH ?? "").split(":").filter((d) => d.startsWith("/")),
|
|
220
237
|
],
|
|
221
238
|
ownDir: orchestratorDir(session.workspace_id ?? ""),
|
|
239
|
+
quick: quickWorktrees(session.id),
|
|
222
240
|
state: path.join(os.homedir(), ".agent-os"),
|
|
223
241
|
repos: [...repos, WORKTREES_DIR, process.cwd(), path.dirname(dbPath)],
|
|
224
242
|
};
|
|
@@ -287,9 +305,68 @@ export async function orchestratorToolRefusal(
|
|
|
287
305
|
tool: string,
|
|
288
306
|
input: Record<string, unknown>
|
|
289
307
|
): Promise<string | null> {
|
|
308
|
+
if (tool === "Bash") return commandRefusal(session, input.command);
|
|
290
309
|
const arg = WRITE_TOOLS[tool];
|
|
291
310
|
if (!arg) return null;
|
|
292
311
|
const filePath = input[arg];
|
|
293
312
|
if (typeof filePath !== "string") return "No file path to check.";
|
|
294
313
|
return writeRefusal(filePath, await orchestratorWriteBounds(session));
|
|
295
314
|
}
|
|
315
|
+
|
|
316
|
+
// A shell command: one plain command, and git only in its own quick
|
|
317
|
+
// worktrees (git-guard.ts), the folder it names resolved like a write's.
|
|
318
|
+
async function commandRefusal(
|
|
319
|
+
session: Session,
|
|
320
|
+
command: unknown
|
|
321
|
+
): Promise<string | null> {
|
|
322
|
+
const read = readCommand(command);
|
|
323
|
+
if (read.refusal !== null || read.dir === null) return read.refusal;
|
|
324
|
+
const quick = quickWorktrees(session.id);
|
|
325
|
+
const dir = read.dir;
|
|
326
|
+
if (!dir.startsWith("/") || !quick.length)
|
|
327
|
+
return `Refused: git runs only in one of your quick changes' worktrees, named by its full path (${quick.length ? quick.join(", ") : "you have none open; start one with quick_change"}).`;
|
|
328
|
+
return (await isQuickRoot(dir, quick))
|
|
329
|
+
? null
|
|
330
|
+
: `Refused: ${read.dir} isn't the root of one of your quick changes' worktrees (${quick.join(", ")}). Git runs at the root only, never in a subfolder, a project checkout or another session's worktree.`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Whether a folder is inside one of these worktrees by every way the
|
|
334
|
+
// system could read it: as named, with `..` taken by name, and with links
|
|
335
|
+
// followed.
|
|
336
|
+
export async function insideQuick(
|
|
337
|
+
dir: string,
|
|
338
|
+
quick: string[]
|
|
339
|
+
): Promise<boolean> {
|
|
340
|
+
try {
|
|
341
|
+
const forms = [
|
|
342
|
+
path.resolve(dir),
|
|
343
|
+
await physicalPath(dir),
|
|
344
|
+
await physicalPath(path.resolve(dir)),
|
|
345
|
+
];
|
|
346
|
+
const roots = (
|
|
347
|
+
await Promise.all(
|
|
348
|
+
quick.map(async (q) => [path.resolve(q), await physicalPath(q)])
|
|
349
|
+
)
|
|
350
|
+
).flat();
|
|
351
|
+
return forms.every((f) => roots.some((r) => within(f, r)));
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Whether a folder is exactly the root of one of these worktrees (as named,
|
|
358
|
+
// and with links followed), where git finds the real git dir.
|
|
359
|
+
async function isQuickRoot(dir: string, quick: string[]): Promise<boolean> {
|
|
360
|
+
if (!(await insideQuick(dir, quick))) return false;
|
|
361
|
+
try {
|
|
362
|
+
const forms = [path.resolve(dir), await physicalPath(dir)];
|
|
363
|
+
const roots = (
|
|
364
|
+
await Promise.all(
|
|
365
|
+
quick.map(async (q) => [path.resolve(q), await physicalPath(q)])
|
|
366
|
+
)
|
|
367
|
+
).flat();
|
|
368
|
+
return forms.every((f) => roots.includes(f));
|
|
369
|
+
} catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Session } from "../db";
|
|
2
|
+
import { stopChat } from "../chat/runner";
|
|
3
|
+
import { waitForExit } from "../chat/worker/client";
|
|
4
|
+
import { endTmuxSession } from "../tmux/end";
|
|
5
|
+
|
|
6
|
+
type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
|
|
7
|
+
|
|
8
|
+
// A session's agent, ended: its chat worker, and its own tmux session (a
|
|
9
|
+
// chat session can still own one). The one call that delete, done, drop and
|
|
10
|
+
// finish share.
|
|
11
|
+
export async function endSessionAgent(s: Agent): Promise<void> {
|
|
12
|
+
stopChat(s.id);
|
|
13
|
+
void waitForExit(s.id).catch(() => {});
|
|
14
|
+
await endTmuxSession(s.host_id, s.tmux_name);
|
|
15
|
+
}
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface SidebarRow {
|
|
|
41
41
|
working: boolean;
|
|
42
42
|
// Orchestration workers, nested under their conductor.
|
|
43
43
|
workers: SidebarRow[];
|
|
44
|
+
// Its task's state, when it's a task.
|
|
45
|
+
task?: TaskState;
|
|
46
|
+
// An orchestrator's quick changes (orchestrator/quick.ts), nested under it.
|
|
47
|
+
quick?: SidebarRow[];
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export interface Shelves {
|
|
@@ -113,6 +117,8 @@ function toRow(input: ShelfInput, session: Session): SidebarRow {
|
|
|
113
117
|
unread: !!status?.unread,
|
|
114
118
|
working: status?.status === "running",
|
|
115
119
|
workers: [],
|
|
120
|
+
task: input.tasks[session.id],
|
|
121
|
+
quick: [],
|
|
116
122
|
};
|
|
117
123
|
}
|
|
118
124
|
|
|
@@ -129,6 +135,15 @@ export function buildShelves(input: ShelfInput): Shelves {
|
|
|
129
135
|
|
|
130
136
|
const top: SidebarRow[] = [];
|
|
131
137
|
for (const row of rows) {
|
|
138
|
+
// A quick change always sits under the orchestrator that makes it: it's
|
|
139
|
+
// that orchestrator's to carry, never one that needs you.
|
|
140
|
+
const owner = row.session.quick_of
|
|
141
|
+
? byId.get(row.session.quick_of)
|
|
142
|
+
: undefined;
|
|
143
|
+
if (owner) {
|
|
144
|
+
(owner.quick ??= []).push(row);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
132
147
|
const conductor = row.session.conductor_session_id
|
|
133
148
|
? byId.get(row.session.conductor_session_id)
|
|
134
149
|
: undefined;
|
|
@@ -200,8 +215,11 @@ export function sameRow(a: SidebarRow, b: SidebarRow): boolean {
|
|
|
200
215
|
a.unread === b.unread &&
|
|
201
216
|
a.working === b.working &&
|
|
202
217
|
sameStatus(a.status, b.status) &&
|
|
218
|
+
a.task === b.task &&
|
|
203
219
|
a.workers.length === b.workers.length &&
|
|
204
|
-
a.workers.every((w, i) => sameRow(w, b.workers[i]))
|
|
220
|
+
a.workers.every((w, i) => sameRow(w, b.workers[i])) &&
|
|
221
|
+
(a.quick?.length ?? 0) === (b.quick?.length ?? 0) &&
|
|
222
|
+
(a.quick ?? []).every((q, i) => sameRow(q, b.quick![i]))
|
|
205
223
|
);
|
|
206
224
|
}
|
|
207
225
|
|
package/lib/stacks/guard.ts
CHANGED
|
@@ -12,6 +12,17 @@ export function refusalFor(
|
|
|
12
12
|
): string | null {
|
|
13
13
|
if (!parent) return null;
|
|
14
14
|
const who = itemName(parent);
|
|
15
|
+
// A dropped parent never merges: once the PR moved off its branch
|
|
16
|
+
// (GitHub retargets it when the branch is deleted), there is nothing to
|
|
17
|
+
// wait for, and the review of the new base reads the parent's commits.
|
|
18
|
+
// A failed one can be retried, so it still blocks.
|
|
19
|
+
if (
|
|
20
|
+
parent.status === "dropped" &&
|
|
21
|
+
parentBranch &&
|
|
22
|
+
item.base_branch &&
|
|
23
|
+
item.base_branch !== parentBranch
|
|
24
|
+
)
|
|
25
|
+
return null;
|
|
15
26
|
if (parent.status !== "merged") {
|
|
16
27
|
const state =
|
|
17
28
|
parent.status === "pr" ? "has not merged" : `is ${parent.status}`;
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { republishAfterMerge } from "../lumifyhub/publish";
|
|
|
10
10
|
import { restackAfterMerge } from "../stacks/restack";
|
|
11
11
|
import { itemName, liveChildren, signOffRefusal } from "../stacks/guard";
|
|
12
12
|
import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
|
|
13
|
-
import {
|
|
13
|
+
import { endSessionAgent } from "../sessions/end";
|
|
14
14
|
import { run } from "./gh";
|
|
15
15
|
import { gitFetch, gitPush } from "../git-exec";
|
|
16
16
|
import {
|
|
@@ -39,13 +39,7 @@ async function cleanup(
|
|
|
39
39
|
opts: { keepRemoteBranch?: boolean; merged?: MergedAs } = {}
|
|
40
40
|
): Promise<void> {
|
|
41
41
|
const keepRemoteBranch = opts.keepRemoteBranch ?? false;
|
|
42
|
-
|
|
43
|
-
if (session.view === "chat") stopChat(session.id);
|
|
44
|
-
await run(
|
|
45
|
-
"tmux",
|
|
46
|
-
["kill-session", "-t", `=${session.tmux_name}`],
|
|
47
|
-
repo
|
|
48
|
-
).catch(() => {});
|
|
42
|
+
await endSessionAgent(session);
|
|
49
43
|
// Never waited on: the row is read now, and Postgres can't hold this up.
|
|
50
44
|
void dropSessionDatabase(session.id);
|
|
51
45
|
if (session.worktree_path && opts.merged) {
|
|
@@ -127,9 +121,16 @@ export async function signOffTask(
|
|
|
127
121
|
if (!repo) throw new Error("Task has no project");
|
|
128
122
|
const refusal = signOffRefusal(id);
|
|
129
123
|
if (refusal) throw new Error(refusal);
|
|
124
|
+
// prFor follows a retarget onto the record, so compare with what the
|
|
125
|
+
// checks above were judged against.
|
|
126
|
+
const judgedBase = session.base_branch;
|
|
130
127
|
const pr = await prFor(session, true);
|
|
131
128
|
const verdict = canSignOff(pr);
|
|
132
129
|
if (!verdict.ok) throw new Error(verdict.reason);
|
|
130
|
+
if (pr!.base && pr!.base !== judgedBase)
|
|
131
|
+
throw new Error(
|
|
132
|
+
`PR #${pr!.number} targets ${pr!.base}, not its base ${judgedBase ?? "(none)"}: its review and checks were for the old base`
|
|
133
|
+
);
|
|
133
134
|
if (opts.head && pr!.head && pr!.head !== opts.head)
|
|
134
135
|
throw new Error(
|
|
135
136
|
`The PR moved to ${pr!.head.slice(0, 7)} after ${opts.head.slice(0, 7)} was checked`
|
package/lib/tasks/gh.ts
CHANGED
|
@@ -54,6 +54,7 @@ export interface ListedPR {
|
|
|
54
54
|
isCrossRepository?: boolean;
|
|
55
55
|
createdAt?: string;
|
|
56
56
|
mergeable?: TaskPR["mergeable"];
|
|
57
|
+
baseRefName?: string;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export interface FindPROpts {
|
|
@@ -81,7 +82,7 @@ export async function findPRStrict(
|
|
|
81
82
|
"--limit",
|
|
82
83
|
"10",
|
|
83
84
|
"--json",
|
|
84
|
-
"number,url,state,headRefOid,statusCheckRollup,body,isCrossRepository,createdAt,mergeable",
|
|
85
|
+
"number,url,state,headRefOid,statusCheckRollup,body,isCrossRepository,createdAt,mergeable,baseRefName",
|
|
85
86
|
],
|
|
86
87
|
repoDir,
|
|
87
88
|
15000
|
|
@@ -118,6 +119,7 @@ export async function toTaskPR(repoDir: string, pr: ListedPR): Promise<TaskPR> {
|
|
|
118
119
|
pr.body === undefined ? undefined : (attributionIn(pr.body) ?? null),
|
|
119
120
|
scopeChange: parseScopeChange(pr.body),
|
|
120
121
|
...(pr.mergeable && { mergeable: pr.mergeable }),
|
|
122
|
+
...(pr.baseRefName && { base: pr.baseRefName }),
|
|
121
123
|
};
|
|
122
124
|
}
|
|
123
125
|
|