@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.
Files changed (56) hide show
  1. package/README.md +9 -0
  2. package/app/api/sessions/[id]/route.test.ts +66 -0
  3. package/app/api/sessions/[id]/route.ts +10 -7
  4. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  5. package/app/api/tmux/kill-all/route.ts +2 -1
  6. package/components/SessionList/OrchestratorPinRow.tsx +12 -6
  7. package/components/SessionList/QuickChangeRow.tsx +58 -0
  8. package/components/SessionList/SessionRow.tsx +4 -0
  9. package/components/Tasks/TaskRow.tsx +2 -17
  10. package/components/Tasks/task-state-labels.ts +18 -0
  11. package/lib/away/sweep.ts +2 -0
  12. package/lib/chat/hold.ts +6 -2
  13. package/lib/chat/runner.ts +5 -0
  14. package/lib/chat/worker/client.ts +2 -3
  15. package/lib/db/migrations.ts +17 -0
  16. package/lib/db/types.ts +3 -0
  17. package/lib/done/index.ts +2 -8
  18. package/lib/done/worktree.ts +9 -2
  19. package/lib/orchestration.ts +4 -5
  20. package/lib/orchestrator/act.ts +3 -6
  21. package/lib/orchestrator/ask-approvals.ts +11 -0
  22. package/lib/orchestrator/brief.ts +7 -4
  23. package/lib/orchestrator/conditions.ts +2 -1
  24. package/lib/orchestrator/describe.ts +1 -1
  25. package/lib/orchestrator/facts.ts +13 -0
  26. package/lib/orchestrator/git-guard.ts +201 -0
  27. package/lib/orchestrator/pr-wake.ts +9 -1
  28. package/lib/orchestrator/quick-store.ts +18 -0
  29. package/lib/orchestrator/quick.ts +307 -0
  30. package/lib/orchestrator/rebase.ts +18 -2
  31. package/lib/orchestrator/repo.ts +78 -13
  32. package/lib/orchestrator/review.ts +21 -0
  33. package/lib/orchestrator/serve.ts +17 -0
  34. package/lib/orchestrator/signoff.ts +37 -6
  35. package/lib/orchestrator/task-state.ts +2 -0
  36. package/lib/orchestrator/tool-names.ts +9 -2
  37. package/lib/orchestrator/tool-schemas.ts +26 -0
  38. package/lib/orchestrator/tools.ts +4 -0
  39. package/lib/orchestrator/write-guard.ts +80 -3
  40. package/lib/sessions/end.ts +15 -0
  41. package/lib/sidebar/shelves.ts +19 -1
  42. package/lib/stacks/guard.ts +11 -0
  43. package/lib/tasks/finish.ts +9 -8
  44. package/lib/tasks/gh.ts +3 -1
  45. package/lib/tasks/index.ts +18 -6
  46. package/lib/tasks/move-guard.ts +2 -0
  47. package/lib/tasks/move.ts +2 -6
  48. package/lib/tasks/pr-poll.ts +1 -1
  49. package/lib/tasks/queue.ts +3 -2
  50. package/lib/tasks/session.ts +63 -2
  51. package/lib/tasks/start.ts +2 -4
  52. package/lib/tasks/state.ts +3 -0
  53. package/lib/tmux/end.ts +33 -0
  54. package/lib/tmux/orphans.ts +146 -0
  55. package/package.json +1 -1
  56. package/server.ts +9 -0
@@ -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 { git, gitFetch, gitOk, worktreeAdd, worktreeRemove } from "../git-exec";
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 base = task.base_branch || "main";
26
- await gitFetch(repo, [
27
- "--quiet",
28
- "--",
29
- "origin",
30
- base,
31
- task.branch_name ?? base,
32
- ]).catch(() => {});
33
- const remote = `origin/${base}`;
34
- const has = await gitOk(repo, ["rev-parse", "--verify", "--quiet", remote]);
35
- return has ? remote : base;
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: codeReviewGate({
95
- head: sha,
96
- attribution: pr.attribution,
97
- review: (at) => getCheck(task.id, at, "review"),
98
- restacked: restackedOf(task),
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: its worker checks each
43
- // write first (write-guard.ts).
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
- const repo = repos.find((r) => within(target, r));
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
- const own = ownDir.some((d) => within(target, d));
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
+ }
@@ -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
 
@@ -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}`;
@@ -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 { stopChat } from "../chat/runner";
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
- // A chat task's agent runs in its chat worker, not the task's tmux.
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