@saadnvd1/agent-os 0.3.588 → 0.3.592

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.
@@ -0,0 +1,117 @@
1
+ // A gate that failed twice holds the task at once, but Saad's ask waits
2
+ // until the task's agent is done: idle, nothing sent to it lately, and CI
3
+ // settled on its latest head. Then the ask goes up at that head, the commit
4
+ // he'd merge; if the head moves after that, followAskHeads moves the same ask.
5
+
6
+ import { db, type Session } from "../db";
7
+ import { prFor } from "../tasks/session";
8
+ import { latestApproval } from "./ask-approvals";
9
+ import { askSaadAbout } from "./escalate";
10
+ import { getCheck } from "./checks";
11
+ import { openBySubject, workSubject } from "./asks";
12
+ import { recentlyMessaged } from "./conditions";
13
+ import type { SessionFacts } from "./facts";
14
+ import {
15
+ failedTwiceWhy,
16
+ heldAskFailures,
17
+ releaseHeldAsks,
18
+ type FailureRow,
19
+ } from "./gates";
20
+ import { getOrchestrator } from "./home";
21
+ import { commitTime, fetchRefs, repoOf } from "./repo";
22
+ import { ciSettleIn, SETTLE_S } from "./task-state";
23
+
24
+ // Long enough for the orchestrator to send the findings that caused the
25
+ // escalation; the hour is the most a stuck agent can hide an escalation.
26
+ export const HOLD_MIN_MS = SETTLE_S * 1000;
27
+ export const HOLD_MAX_MS = 60 * 60 * 1000;
28
+
29
+ const short = (sha: string) => sha.slice(0, 7);
30
+ const sqliteMs = (t: string) => Date.parse(`${t.replace(" ", "T")}Z`);
31
+
32
+ export async function raiseHeldAsks(
33
+ workspaceId: string,
34
+ facts: SessionFacts[],
35
+ now = Date.now()
36
+ ): Promise<void> {
37
+ const byTask = new Map<string, FailureRow[]>();
38
+ for (const row of heldAskFailures(workspaceId))
39
+ byTask.set(row.session_id, [...(byTask.get(row.session_id) ?? []), row]);
40
+ if (!byTask.size) return;
41
+ const orch = getOrchestrator(workspaceId);
42
+ const messaged = orch ? recentlyMessaged(orch.id, now) : new Set<string>();
43
+ for (const [taskId, rows] of byTask) {
44
+ try {
45
+ await raiseFor(workspaceId, taskId, rows, facts, messaged, now);
46
+ } catch (error) {
47
+ console.error(`Raising ${taskId}'s held ask:`, error);
48
+ }
49
+ }
50
+ }
51
+
52
+ async function raiseFor(
53
+ workspaceId: string,
54
+ taskId: string,
55
+ rows: FailureRow[],
56
+ facts: SessionFacts[],
57
+ messaged: Set<string>,
58
+ now: number
59
+ ): Promise<void> {
60
+ const f = facts.find((x) => x.id === taskId);
61
+ const task = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(taskId) as
62
+ | Session
63
+ | undefined;
64
+ // Gone, merged or dropped: nothing left to ask. An open ask or an
65
+ // unspent approval already covers it (followAskHeads, heldVerdict).
66
+ if (
67
+ !task ||
68
+ task.task_status !== "running" ||
69
+ openBySubject(workspaceId, workSubject(taskId)) ||
70
+ latestApproval(workspaceId, workSubject(taskId))
71
+ )
72
+ return releaseHeldAsks(taskId);
73
+ const heldFor =
74
+ now - Math.min(...rows.map((r) => sqliteMs(r.ask_held_at as string)));
75
+ const overdue = heldFor >= HOLD_MAX_MS;
76
+ // No facts yet is a wait, but never past the hour.
77
+ if (!f && !overdue) return;
78
+ // A gh failure throws and keeps the hold: only GitHub saying the PR isn't
79
+ // open releases it.
80
+ const pr = await prFor(task, true, true);
81
+ if (pr?.state !== "OPEN" || !pr.head) return releaseHeldAsks(taskId);
82
+ if (!overdue && f) {
83
+ if (f.status === "running" || f.compacting) return;
84
+ if (messaged.has(taskId) || heldFor < HOLD_MIN_MS) return;
85
+ if (getCheck(taskId, pr.head, "review")?.status === "running") return;
86
+ const repo = repoOf(task);
87
+ // The head may be a commit this clone hasn't fetched yet.
88
+ await fetchRefs(repo, task);
89
+ const committedAt = await commitTime(repo, pr.head);
90
+ if (pr.checks === "pending") return;
91
+ if (pr.checks === "pass") {
92
+ const left = ciSettleIn({
93
+ workspaceId,
94
+ taskId,
95
+ sha: pr.head,
96
+ checkCount: pr.checkCount ?? 0,
97
+ committedAt,
98
+ now,
99
+ });
100
+ if (left > 0) return;
101
+ } else if (now / 1000 - committedAt < SETTLE_S) return;
102
+ }
103
+ const last = rows.find((r) => r.last_sha)?.last_sha;
104
+ const why = [
105
+ ...rows.map((r) => failedTwiceWhy(r.gate, r.last_reason ?? "")),
106
+ ...(last && last !== pr.head
107
+ ? [
108
+ `Asked at ${short(pr.head)}, its head after the fixes since ${short(last)}.`,
109
+ ]
110
+ : []),
111
+ ...(overdue
112
+ ? [`${task.name} was still working after an hour; this is its head now.`]
113
+ : []),
114
+ ].join("\n");
115
+ askSaadAbout(workspaceId, task, why, pr.url, pr.head);
116
+ releaseHeldAsks(taskId);
117
+ }
@@ -40,9 +40,12 @@ export function heldVerdict(
40
40
  text: `${task.name}: ${why}. Asked him again; don't retry.`,
41
41
  };
42
42
  }
43
+ const waiting = held.some((h) => h.ask_held_at)
44
+ ? " His ask goes up once its agent is idle and CI has settled on its latest head."
45
+ : "";
43
46
  return {
44
47
  ok: false,
45
48
  wait: false,
46
- text: `${task.name} is with Saad (${held.map((h) => h.gate).join(", ")}: ${held[0].last_reason}). Only he can merge it now, or approve it on his asks list.`,
49
+ text: `${task.name} is with Saad (${held.map((h) => h.gate).join(", ")}: ${held[0].last_reason}). Only he can merge it now, or approve it on his asks list.${waiting}`,
47
50
  };
48
51
  }
@@ -12,10 +12,13 @@ import path from "path";
12
12
  import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
13
13
  import { db, type Session } from "../db";
14
14
  import { getWorkspace } from "../workspaces";
15
+ import { refuseRealStateUnderTest } from "../test-home-guard";
15
16
 
16
17
  // It works through tools, not in a repo: a scratch folder of its own.
17
18
  export const orchestratorDir = (workspaceId: string) =>
18
- path.join(os.homedir(), ".agent-os", "orchestrators", workspaceId);
19
+ refuseRealStateUnderTest(
20
+ path.join(os.homedir(), ".agent-os", "orchestrators", workspaceId)
21
+ );
19
22
 
20
23
  export function getOrchestrator(workspaceId: string): Session | null {
21
24
  return (
@@ -17,6 +17,7 @@ import { settleStaleAsks } from "./ask-settle";
17
17
  import { brakesOn, liftBrake } from "./brakes";
18
18
  import { anyArmed, mergeArmed } from "./armed";
19
19
  import { followAskHeads } from "./ask-heads";
20
+ import { raiseHeldAsks } from "./held-asks";
20
21
  import { rebaseConflicting } from "./rebase";
21
22
  import { wakeTasks } from "./pr-wake";
22
23
  import { awayTick } from "../away/run";
@@ -79,6 +80,9 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
79
80
  await followAskHeads(workspaceId, facts).catch((error) =>
80
81
  console.error(`Following ask heads in ${workspaceId}:`, error)
81
82
  );
83
+ await raiseHeldAsks(workspaceId, facts).catch((error) =>
84
+ console.error(`Raising held asks in ${workspaceId}:`, error)
85
+ );
82
86
  await rebaseConflicting(workspaceId, facts);
83
87
  await mergeArmed(workspaceId, facts);
84
88
  })()
@@ -113,11 +113,10 @@ export async function flushUndelivered(
113
113
  ).run(m.id);
114
114
  continue;
115
115
  }
116
- const line = wakeLine({
117
- fromName: m.from_name,
118
- fromId: m.from_id,
119
- body: m.body,
120
- });
116
+ const line = wakeLine(
117
+ { fromName: m.from_name, fromId: m.from_id, body: m.body },
118
+ { inline: true }
119
+ );
121
120
  const res = await relayToHeldTask(task, line, m.from_name, m.id, false);
122
121
  if (res.state === "failed") return;
123
122
  db.prepare(
@@ -0,0 +1,23 @@
1
+ import os from "os";
2
+ import path from "path";
3
+
4
+ // Under the test runner, refuse a path inside the real ~/.agent-os: a test
5
+ // that resolves there would leave folders in the owner's state. The real
6
+ // home comes from the account, not $HOME, which the test setup replaces.
7
+ export function refuseRealStateUnderTest(p: string): string {
8
+ if (!process.env.VITEST) return p;
9
+ let real: string;
10
+ try {
11
+ real = os.userInfo().homedir;
12
+ } catch {
13
+ return p;
14
+ }
15
+ const state = path.join(real, ".agent-os");
16
+ const resolved = path.resolve(p);
17
+ if (resolved === state || resolved.startsWith(state + path.sep)) {
18
+ throw new Error(
19
+ `Refusing to use ${state} under the test runner (${resolved}): point HOME at a temp folder`
20
+ );
21
+ }
22
+ return p;
23
+ }
package/lib/worktrees.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  slugify,
13
13
  generateBranchName,
14
14
  } from "./git";
15
+ import { refuseRealStateUnderTest } from "./test-home-guard";
15
16
  import { installWorktreeHooks } from "./worktree-hooks";
16
17
  import { gitResult, isGitError, worktreeList } from "./git-exec";
17
18
  import { addWorktree } from "./worktree-add";
@@ -43,8 +44,13 @@ export async function mainCheckoutOf(worktreePath: string): Promise<string> {
43
44
  }
44
45
  }
45
46
 
47
+ /** Where task worktrees live: ~/.agent-os/worktrees. */
48
+ export function worktreesRoot(home = os.homedir()): string {
49
+ return refuseRealStateUnderTest(path.join(home, ".agent-os", "worktrees"));
50
+ }
51
+
46
52
  // Base directory for all worktrees
47
- export const WORKTREES_DIR = path.join(os.homedir(), ".agent-os", "worktrees");
53
+ export const WORKTREES_DIR = worktreesRoot();
48
54
 
49
55
  export interface WorktreeInfo {
50
56
  worktreePath: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.588",
3
+ "version": "0.3.592",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"