@saadnvd1/agent-os 0.3.465 → 0.3.467

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 (45) hide show
  1. package/README.md +14 -4
  2. package/app/api/git/commit/route.ts +4 -3
  3. package/app/api/git/file-content/route.ts +4 -15
  4. package/app/api/git/history/[hash]/diff/route.ts +1 -1
  5. package/app/api/git/history/[hash]/route.ts +1 -1
  6. package/app/api/git/history/route.ts +1 -1
  7. package/app/api/git/push/route.ts +2 -2
  8. package/app/api/sessions/[id]/pr/route.ts +2 -2
  9. package/lib/away/deploy.ts +3 -7
  10. package/lib/away/run.ts +6 -5
  11. package/lib/build.ts +2 -6
  12. package/lib/chat/files.ts +31 -9
  13. package/lib/db/migrations.ts +31 -0
  14. package/lib/done/remote.ts +5 -6
  15. package/lib/done/testing.ts +2 -7
  16. package/lib/done/worktree.ts +2 -2
  17. package/lib/git-exec.ts +334 -0
  18. package/lib/git-history.ts +29 -41
  19. package/lib/git-status.ts +45 -124
  20. package/lib/git.ts +44 -159
  21. package/lib/lumifyhub/publish.ts +2 -2
  22. package/lib/orchestrator/armed.ts +2 -2
  23. package/lib/orchestrator/brief.ts +1 -1
  24. package/lib/orchestrator/diff.ts +15 -13
  25. package/lib/orchestrator/escalate.ts +7 -7
  26. package/lib/orchestrator/gates.ts +36 -16
  27. package/lib/orchestrator/rebase.ts +20 -24
  28. package/lib/orchestrator/repo-slug.ts +4 -7
  29. package/lib/orchestrator/repo.ts +24 -35
  30. package/lib/orchestrator/signoff.ts +1 -1
  31. package/lib/pr-generation.ts +2 -1
  32. package/lib/pr.ts +16 -57
  33. package/lib/sessions/worktree-setup.ts +17 -13
  34. package/lib/stacks/start.ts +7 -11
  35. package/lib/tasks/branch-worktree.ts +3 -2
  36. package/lib/tasks/finish.ts +5 -6
  37. package/lib/tasks/gh.ts +5 -0
  38. package/lib/tasks/move-testing.ts +2 -2
  39. package/lib/tasks/move.ts +3 -2
  40. package/lib/tasks/project-ref.ts +12 -19
  41. package/lib/tasks/session.ts +5 -5
  42. package/lib/worktree-hooks.ts +3 -7
  43. package/lib/worktree-placed.ts +3 -10
  44. package/lib/worktrees.ts +4 -42
  45. package/package.json +1 -1
package/README.md CHANGED
@@ -908,10 +908,13 @@ it may do on its own, and the lines that always come back to you as asks.
908
908
  `BLOCKED:` or waiting, the diff stays in scope (no secrets, not only
909
909
  lockfiles, nothing outside the repo, within the card) and its stack parent
910
910
  has merged, and the PR body's Code review section names that commit. `land` judges each item again at its own head right before
911
- merging it. The second failure of a gate or a repo with no CI goes to you
912
- as an ask, and the orchestrator stops merging that task. Failures count per
913
- task and gate: a review that blocks counts when its verdict arrives, once
914
- per commit, whether or not `sign_off` ever reads it.
911
+ merging it. A gate failing again on a different commit, or a repo with no
912
+ CI, goes to you as an ask, and the orchestrator stops merging that task.
913
+ A failure counts once per task, gate and head commit, however many times
914
+ and from wherever that commit is judged (the armed watcher, CI finishing,
915
+ `sign_off`, `done`, `land`); a pass in between doesn't reset it. A review
916
+ that blocks counts when its verdict arrives, whether or not `sign_off`
917
+ ever reads it.
915
918
  - **Arm to merge:** `arm` (or **Arm merge** on a task's row) makes AgentOS
916
919
  merge the task's PR itself the moment every `sign_off` gate passes at its
917
920
  head, starting the review once CI is green, so nothing polls `sign_off`.
@@ -1258,6 +1261,13 @@ One Node process serves every terminal, chat stream and request, so a route
1258
1261
  handler in `app/api/` may not call a `*Sync` child_process or fs function:
1259
1262
  ESLint refuses it. Use the async form with a timeout.
1260
1263
 
1264
+ Every git process starts in `lib/git-exec.ts`: `git()`, `gitResult()`,
1265
+ `gitOk()` and named operations (`revParse`, `worktreeAdd`, `gitPush`, …). It
1266
+ sets one timeout, kills a hung git, never lets git prompt, keeps the server's
1267
+ tmux out of hooks and rejects with a `GitError` carrying the exit code and
1268
+ stderr. ESLint refuses git spawned anywhere else in `lib/`, `app/` or
1269
+ `server.ts`.
1270
+
1261
1271
  `scripts/redeploy` (and `scripts/autodeploy`, which calls it) builds into
1262
1272
  `.next-build` while the live server keeps serving `.next`. The build is swapped
1263
1273
  in by rename only once it finished with a `BUILD_ID`, right before the restart.
@@ -45,7 +45,8 @@ export async function POST(request: NextRequest) {
45
45
 
46
46
  if (
47
47
  branchName &&
48
- (typeof branchName !== "string" || !isValidBranchName(path, branchName))
48
+ (typeof branchName !== "string" ||
49
+ !(await isValidBranchName(path, branchName)))
49
50
  ) {
50
51
  return NextResponse.json(
51
52
  { error: "Invalid branch name" },
@@ -64,8 +65,8 @@ export async function POST(request: NextRequest) {
64
65
 
65
66
  // Create new branch if on main/master and branch name provided
66
67
  let newBranch = false;
67
- if (branchName && isMainBranch(path)) {
68
- createBranch(path, branchName);
68
+ if (branchName && (await isMainBranch(path))) {
69
+ await createBranch(path, branchName);
69
70
  newBranch = true;
70
71
  }
71
72
 
@@ -1,10 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { execFile } from "child_process";
3
- import { promisify } from "util";
2
+ import { git } from "@/lib/git-exec";
4
3
  import { expandPath } from "@/lib/git-status";
5
4
 
6
- const execFileAsync = promisify(execFile);
7
-
8
5
  /**
9
6
  * GET /api/git/file-content?path=...&file=...
10
7
  * Get file content from git HEAD (original version before changes)
@@ -33,17 +30,9 @@ export async function GET(request: NextRequest) {
33
30
 
34
31
  try {
35
32
  // Get file content from HEAD
36
- const { stdout: content } = await execFileAsync(
37
- "git",
38
- ["show", `HEAD:${file}`],
39
- {
40
- cwd: expandedPath,
41
- encoding: "utf-8",
42
- maxBuffer: 10 * 1024 * 1024, // 10MB
43
- timeout: 30_000,
44
- killSignal: "SIGKILL",
45
- }
46
- );
33
+ const content = await git(expandedPath, ["show", `HEAD:${file}`], {
34
+ timeout: 30_000,
35
+ });
47
36
 
48
37
  return NextResponse.json({ content });
49
38
  } catch (gitError: unknown) {
@@ -31,7 +31,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
31
31
  );
32
32
  }
33
33
 
34
- const diff = getCommitFileDiff(path, hash, file);
34
+ const diff = await getCommitFileDiff(path, hash, file);
35
35
  return NextResponse.json({ diff });
36
36
  } catch (error) {
37
37
  console.error("Error getting commit file diff:", error);
@@ -23,7 +23,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
23
23
  );
24
24
  }
25
25
 
26
- const commit = getCommitDetail(path, hash);
26
+ const commit = await getCommitDetail(path, hash);
27
27
  if (!commit) {
28
28
  return NextResponse.json({ error: "Commit not found" }, { status: 404 });
29
29
  }
@@ -15,7 +15,7 @@ export async function GET(request: NextRequest) {
15
15
  );
16
16
  }
17
17
 
18
- const commits = getCommitHistory(path, limit);
18
+ const commits = await getCommitHistory(path, limit);
19
19
  return NextResponse.json({ commits });
20
20
  } catch (error) {
21
21
  console.error("Error getting commit history:", error);
@@ -28,7 +28,7 @@ export async function POST(request: NextRequest) {
28
28
  }
29
29
 
30
30
  // Check if remote exists
31
- const remoteUrl = getRemoteUrl(path);
31
+ const remoteUrl = await getRemoteUrl(path);
32
32
  if (!remoteUrl) {
33
33
  return NextResponse.json(
34
34
  { error: "No remote origin configured" },
@@ -48,7 +48,7 @@ export async function POST(request: NextRequest) {
48
48
  }
49
49
 
50
50
  // Push (set upstream if needed)
51
- const needsUpstream = !hasUpstream(path);
51
+ const needsUpstream = !(await hasUpstream(path));
52
52
  const output = await push(path, needsUpstream);
53
53
 
54
54
  forgetGitStatus(path);
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { execFile } from "child_process";
3
3
  import { promisify } from "util";
4
4
  import { getDb, queries, type Session } from "@/lib/db";
5
+ import { gitPush } from "@/lib/git-exec";
5
6
 
6
7
  const execFileAsync = promisify(execFile);
7
8
 
@@ -69,8 +70,7 @@ async function createPR(
69
70
  ): Promise<PRInfo> {
70
71
  // First push the branch if not already pushed
71
72
  try {
72
- await execFileAsync("git", ["push", "-u", "origin", "--", branchName], {
73
- cwd: projectPath,
73
+ await gitPush(projectPath, ["-u", "origin", "--", branchName], {
74
74
  timeout: 30000,
75
75
  });
76
76
  } catch {
@@ -9,7 +9,7 @@
9
9
 
10
10
  import fs from "fs";
11
11
  import type { ProjectConfig } from "../project-config";
12
- import { run } from "../tasks/gh";
12
+ import { gitFetch, isAncestor } from "../git-exec";
13
13
  import { expandHome } from "../tasks/session";
14
14
  import { selfUrl } from "../agents/self-env";
15
15
  import { commitTime } from "../orchestrator/repo";
@@ -61,15 +61,11 @@ async function contains(
61
61
  sha: string
62
62
  ): Promise<boolean> {
63
63
  if (tip === sha) return true;
64
- const ancestor = () =>
65
- run("git", ["merge-base", "--is-ancestor", sha, tip], repo).then(
66
- () => true,
67
- () => false
68
- );
64
+ const ancestor = () => isAncestor(repo, sha, tip);
69
65
  if (await ancestor()) return true;
70
66
  // The deployed commit may be newer than this checkout knows. A fetch that
71
67
  // fails throws, so stale refs never read as "not deployed".
72
- await run("git", ["fetch", "--quiet", "origin"], repo);
68
+ await gitFetch(repo, ["--quiet", "origin"]);
73
69
  return ancestor();
74
70
  }
75
71
 
package/lib/away/run.ts CHANGED
@@ -23,6 +23,7 @@ import { notifyPhone } from "../notify";
23
23
  import { getProject } from "../projects";
24
24
  import { loadProjectConfig } from "../project-config";
25
25
  import { run } from "../tasks/gh";
26
+ import { git } from "../git-exec";
26
27
  import { expandHome } from "../tasks/session";
27
28
  import { auditId, auditsRoot, writeAtomic } from "../session-audit/store";
28
29
  import {
@@ -199,11 +200,11 @@ async function dirtySessions(ids: string[]): Promise<Set<string>> {
199
200
  // A linked machine's worktree isn't on this disk.
200
201
  if (!s || (s.host_id && s.host_id !== "local")) return;
201
202
  const dir = expandHome(s.worktree_path || s.working_directory);
202
- const out = await run(
203
- "git",
204
- ["status", "--porcelain", "--untracked-files=normal"],
205
- dir
206
- ).catch(() => "");
203
+ const out = await git(dir, [
204
+ "status",
205
+ "--porcelain",
206
+ "--untracked-files=normal",
207
+ ]).catch(() => "");
207
208
  if (out.trim()) dirty.add(id);
208
209
  })
209
210
  );
package/lib/build.ts CHANGED
@@ -3,20 +3,16 @@
3
3
  // A worker whose build isn't the server's is running code from before a
4
4
  // redeploy.
5
5
 
6
- import { execFileSync } from "child_process";
7
6
  import fs from "fs";
8
7
  import path from "path";
9
8
  import type { ChatState } from "./chat/events";
9
+ import { gitSync } from "./git-exec";
10
10
 
11
11
  export function buildId(): string {
12
12
  if (process.env.AGENTOS_BUILD) return process.env.AGENTOS_BUILD;
13
13
  let id = "";
14
14
  try {
15
- id = execFileSync("git", ["rev-parse", "HEAD"], {
16
- cwd: process.cwd(),
17
- encoding: "utf8",
18
- stdio: ["ignore", "pipe", "ignore"],
19
- }).trim();
15
+ id = gitSync(process.cwd(), ["rev-parse", "HEAD"]).trim();
20
16
  } catch {}
21
17
  if (!id) {
22
18
  try {
package/lib/chat/files.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
+ import { git } from "../git-exec";
3
4
  import type { FileSuggestion } from "./events";
4
5
  import {
5
6
  indexPaths,
@@ -33,11 +34,36 @@ export function sweepFileIndexes(now = Date.now()): void {
33
34
 
34
35
  export const fileIndexCount = () => cache.size;
35
36
 
37
+ const LIST_TIMEOUT_MS = 5000;
38
+
39
+ export interface Lister {
40
+ // The program it needs.
41
+ cmd: string;
42
+ list: (cwd: string) => Promise<string>;
43
+ }
44
+
36
45
  // Ways to list a folder's files with ignored ones left out: ripgrep, or
37
46
  // git where ripgrep isn't installed.
38
- export const LISTERS: [string, string[]][] = [
39
- ["rg", ["--files", "--hidden", "-g", "!.git"]],
40
- ["git", ["ls-files", "--cached", "--others", "--exclude-standard"]],
47
+ export const LISTERS: Lister[] = [
48
+ {
49
+ cmd: "rg",
50
+ list: async (cwd) =>
51
+ (
52
+ await execFileAsync("rg", ["--files", "--hidden", "-g", "!.git"], {
53
+ cwd,
54
+ maxBuffer: 64 * 1024 * 1024,
55
+ timeout: LIST_TIMEOUT_MS,
56
+ })
57
+ ).stdout,
58
+ },
59
+ {
60
+ cmd: "git",
61
+ list: (cwd) =>
62
+ git(cwd, ["ls-files", "--cached", "--others", "--exclude-standard"], {
63
+ timeout: LIST_TIMEOUT_MS,
64
+ maxBuffer: 64 * 1024 * 1024,
65
+ }),
66
+ },
41
67
  ];
42
68
 
43
69
  // The folder's files, for @mentions when no agent is running to match them
@@ -46,13 +72,9 @@ export async function listFiles(
46
72
  cwd: string,
47
73
  listers = LISTERS
48
74
  ): Promise<IndexedPath[]> {
49
- for (const [cmd, args] of listers) {
75
+ for (const { list } of listers) {
50
76
  try {
51
- const { stdout } = await execFileAsync(cmd, args, {
52
- cwd,
53
- maxBuffer: 64 * 1024 * 1024,
54
- timeout: 5000,
55
- });
77
+ const stdout = await list(cwd);
56
78
  return indexPaths(
57
79
  withFolders(stdout.split("\n").filter(Boolean).slice(0, MAX_FILES))
58
80
  );
@@ -1327,6 +1327,37 @@ const migrations: Migration[] = [
1327
1327
  installChangeTriggers(db, TABLES_WATCHED_58);
1328
1328
  },
1329
1329
  },
1330
+ {
1331
+ id: 59,
1332
+ name: "gate_failure_commits",
1333
+ up: (db) => {
1334
+ // Each head a gate failed on, once: a gate's failure counts once per
1335
+ // commit however many times that commit is judged (the armed watcher,
1336
+ // CI finishing, sign_off, done, land), and the second commit it fails
1337
+ // on goes to Saad. The key is what makes it once, across restarts and
1338
+ // concurrent evaluations.
1339
+ db.exec(`
1340
+ CREATE TABLE IF NOT EXISTS orchestrator_gate_failure_commits (
1341
+ session_id TEXT NOT NULL,
1342
+ gate TEXT NOT NULL,
1343
+ sha TEXT NOT NULL,
1344
+ workspace_id TEXT NOT NULL,
1345
+ reason TEXT,
1346
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1347
+ PRIMARY KEY (session_id, gate, sha)
1348
+ );
1349
+ INSERT OR IGNORE INTO orchestrator_gate_failure_commits (session_id, gate, sha, workspace_id, reason)
1350
+ SELECT session_id, gate, last_sha, workspace_id, last_reason
1351
+ FROM orchestrator_gate_failures WHERE last_sha IS NOT NULL;
1352
+ CREATE TRIGGER IF NOT EXISTS gate_failures_clear_commits
1353
+ AFTER DELETE ON orchestrator_gate_failures
1354
+ BEGIN
1355
+ DELETE FROM orchestrator_gate_failure_commits
1356
+ WHERE session_id = OLD.session_id AND gate = OLD.gate;
1357
+ END;
1358
+ `);
1359
+ },
1360
+ },
1330
1361
  ];
1331
1362
 
1332
1363
  // `upTo`: stop after this id (tests that start from an older database).
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { Session } from "../db";
6
6
  import type { TaskPR } from "../tasks";
7
- import { run } from "../tasks/gh";
7
+ import { git, gitPush } from "../git-exec";
8
8
  import { projectPathFor } from "../tasks/session";
9
9
  import { PROTECTED_BRANCHES } from "../tasks/finish";
10
10
  import { liveChildren } from "../stacks/guard";
@@ -23,15 +23,14 @@ export async function deleteMergedRemote(
23
23
  liveChildren(s.id).length
24
24
  )
25
25
  return false;
26
- const line = await run(
27
- "git",
28
- ["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
26
+ const line = await git(
29
27
  repo,
30
- 30000
28
+ ["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
29
+ { timeout: 30000 }
31
30
  ).catch(() => "");
32
31
  const tip = line.trim().split(/\s+/)[0];
33
32
  if (!tip || tip !== pr.head) return false;
34
- return run("git", ["push", "origin", "--delete", branch], repo, 60000)
33
+ return gitPush(repo, ["origin", "--delete", branch], { timeout: 60000 })
35
34
  .then(() => true)
36
35
  .catch(() => false);
37
36
  }
@@ -4,18 +4,13 @@
4
4
  import fs from "fs";
5
5
  import os from "os";
6
6
  import path from "path";
7
- import { execFileSync } from "child_process";
7
+ import { gitSync } from "../git-exec";
8
8
 
9
9
  // Old enough that CI counts as settled on every commit.
10
10
  const env = { ...process.env, GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z" };
11
11
 
12
12
  export const git = (cwd: string, ...args: string[]) =>
13
- execFileSync("git", args, {
14
- cwd,
15
- encoding: "utf8",
16
- stdio: "pipe",
17
- env,
18
- }).trim();
13
+ gitSync(cwd, args, { env }).trim();
19
14
 
20
15
  export function commitFile(cwd: string, file: string, text: string): string {
21
16
  fs.mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true });
@@ -7,7 +7,7 @@
7
7
  import fs from "fs";
8
8
  import type { Session } from "../db";
9
9
  import { getDefaultBranch } from "../git";
10
- import { run } from "../tasks/gh";
10
+ import { git as runGit } from "../git-exec";
11
11
  import { mergePolicy } from "../tasks/merge-policy";
12
12
  import { getProject } from "../projects";
13
13
  import {
@@ -24,7 +24,7 @@ export type WorktreeFate =
24
24
  | { action: "kept"; why: string; path: string };
25
25
 
26
26
  const git = (cwd: string, ...args: string[]) =>
27
- run("git", args, cwd, 30000).then((out) => out.trim());
27
+ runGit(cwd, args, { timeout: 30000 }).then((out) => out.trim());
28
28
 
29
29
  // The main checkout a worktree belongs to.
30
30
  async function repoOf(worktree: string): Promise<string> {