@saadnvd1/agent-os 0.3.484 → 0.3.485

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.
@@ -123,6 +123,7 @@ function fingerprint(
123
123
  return [
124
124
  head,
125
125
  pr.checks,
126
+ pr.mergeable,
126
127
  (f.task?.ciSettleIn ?? 0) > 0,
127
128
  getCheck(task.id, head, "review")?.status,
128
129
  getCheck(task.id, head, "scope")?.status,
@@ -89,6 +89,13 @@ function sessionConditions(
89
89
  ciKey(pr),
90
90
  `${who}: ${ciWord(pr)}${pr.checks === "pass" ? " and settled" : ""}`
91
91
  );
92
+ // Mergeable for a long while with no check in a repository that has CI:
93
+ // CI probably wasn't triggered. News once per head, never an escalation.
94
+ if (pr.checks === "none" && f.task?.ciSilentMs !== undefined)
95
+ add(
96
+ `ci-silent:${pr.number}:${pr.head ?? ""}`,
97
+ `${who}: PR #${pr.number} at ${(pr.head ?? "").slice(0, 7)} has been mergeable for ${Math.floor(f.task.ciSilentMs / 60000)}m with no CI check registered; CI may not have been triggered on this head`
98
+ );
92
99
  }
93
100
 
94
101
  const blocked = f.task?.blocked ?? null;
@@ -17,7 +17,9 @@ export function ciWord(pr: TaskPR): string {
17
17
  ? `CI failed: ${untrusted("CI", pr.failing)}`
18
18
  : "CI failed";
19
19
  if (pr.checks === "pending") return "CI pending";
20
- return "no CI";
20
+ return pr.mergeable === "CONFLICTING"
21
+ ? "conflicts with its base, no CI yet"
22
+ : "no checks yet";
21
23
  }
22
24
 
23
25
  export const shortId = (id: string) => id.slice(0, 8);
@@ -75,6 +75,8 @@ export async function judgeExternal(
75
75
  checkCount: pr.checkCount ?? 0,
76
76
  committedAt: await commitTime(ext.repo, sha),
77
77
  }),
78
+ mergeable: pr.mergeable,
79
+ baseName: ext.base,
78
80
  review,
79
81
  blocked: null,
80
82
  waitingOn: null,
@@ -63,7 +63,7 @@ export type GateTarget =
63
63
  | { kind: "external"; pr: ExternalPR };
64
64
 
65
65
  const FIELDS =
66
- "number,url,state,headRefOid,headRefName,baseRefName,isDraft,labels,isCrossRepository,statusCheckRollup,body,title";
66
+ "number,url,state,headRefOid,headRefName,baseRefName,isDraft,labels,isCrossRepository,statusCheckRollup,body,title,mergeable";
67
67
 
68
68
  interface ViewedPR extends ListedPR {
69
69
  baseRefName?: string;
@@ -18,7 +18,8 @@ import { sessionRowInfo } from "../session-meta";
18
18
  import { taskView, type TaskPR, type TaskState } from "../tasks";
19
19
  import { storedPR } from "../tasks/session";
20
20
  import { commitTime, repoOf } from "./repo";
21
- import { ciSettleIn } from "./task-state";
21
+ import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
22
+ import { repoHasCI } from "./repo-ci";
22
23
 
23
24
  export type FactStatus = "running" | "waiting" | "idle" | "dead";
24
25
 
@@ -47,6 +48,9 @@ export interface SessionFacts {
47
48
  // With CI green on the PR's head: seconds until it counts as settled
48
49
  // (task-state.ts), when the CI-green event goes out.
49
50
  ciSettleIn?: number;
51
+ // Set once a mergeable head in a repository with CI has had no checks
52
+ // for CHECKLESS_MS (task-state.ts): ms it has been without.
53
+ ciSilentMs?: number;
50
54
  } | null;
51
55
  stack: {
52
56
  id: string;
@@ -213,6 +217,20 @@ export async function settleIn(
213
217
  });
214
218
  }
215
219
 
220
+ async function silentFor(
221
+ s: Session,
222
+ pr: TaskPR | null
223
+ ): Promise<number | undefined> {
224
+ if (!pr) return;
225
+ const silent = checklessFor(s.id, pr);
226
+ if (silent === undefined || silent < CHECKLESS_MS) return;
227
+ const ci = await repoHasCI(
228
+ repoOf(s),
229
+ `origin/${pr.base ?? s.base_branch ?? "main"}`
230
+ ).catch(() => "unknown");
231
+ return ci === "yes" ? silent : undefined;
232
+ }
233
+
216
234
  // A finished task's state is in the database: no gh call for it.
217
235
  async function taskFacts(
218
236
  workspaceId: string,
@@ -230,6 +248,7 @@ async function taskFacts(
230
248
  pr: view.pr,
231
249
  blocked: view.blocked,
232
250
  ciSettleIn: await settleIn(workspaceId, s, view.pr),
251
+ ciSilentMs: await silentFor(s, view.pr),
233
252
  };
234
253
  }
235
254
 
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The merge gates, ported from dispatch orchestrate. Each one passes, waits
3
3
  * (not ready yet: CI running or settling, no review of this commit yet),
4
- * fails, or goes straight to Saad (no CI at all). Only a failure counts
4
+ * fails, or goes straight to Saad (the repository has no CI at all, never
5
+ * just a head with no checks). Only a failure counts
5
6
  * against the task, and a gate failing again on a different commit goes to
6
7
  * Saad instead of being retried. A failure counts once per task, gate and
7
8
  * head commit, whichever path judged it (the armed watcher, CI finishing,
@@ -12,9 +13,10 @@
12
13
 
13
14
  import { db } from "../db";
14
15
  import type { CodeReviewGate } from "../tasks/code-review";
15
- import type { ChecksVerdict } from "../tasks/state";
16
+ import type { ChecksVerdict, TaskPR } from "../tasks/state";
16
17
  import type { CheckRow } from "./checks";
17
18
  import { APPROVAL_GATES, mergeApprovalsOn } from "./merge-approvals";
19
+ import type { RepoCI } from "./repo-ci";
18
20
  import { untrusted } from "./untrusted";
19
21
 
20
22
  export type GateName =
@@ -37,6 +39,11 @@ export interface GateInput {
37
39
  failing?: string | null;
38
40
  // Seconds until CI on this commit counts as settled (tasks/task-state).
39
41
  settleIn: number;
42
+ // GitHub's word on merging the PR into its base, and that base's name.
43
+ mergeable?: TaskPR["mergeable"];
44
+ baseName?: string | null;
45
+ // Whether the repository has CI; only read when the head has no checks.
46
+ repoCI?: RepoCI;
40
47
  review: CheckRow | null;
41
48
  // A BLOCKED: line, or an approval or question the agent is waiting on.
42
49
  blocked: string | null;
@@ -68,22 +75,48 @@ function ciGate(i: GateInput): GateOutcome {
68
75
  state: "fail",
69
76
  reason: `CI failed on ${at}${i.failing ? ` (${untrusted("CI", i.failing)})` : ""}`,
70
77
  };
78
+ if (i.checks === "none") return noChecks(i, at);
71
79
  if (i.settleIn > 0)
72
80
  return {
73
81
  gate,
74
82
  state: "wait",
75
- reason:
76
- i.checks === "none"
77
- ? `no CI checks have registered on ${at} yet (looking again in ${i.settleIn}s)`
78
- : `CI is green on ${at} but settling: ${i.settleIn}s more with no new check`,
83
+ reason: `CI is green on ${at} but settling: ${i.settleIn}s more with no new check`,
79
84
  };
80
- if (i.checks === "none")
85
+ return { gate, state: "pass" };
86
+ }
87
+
88
+ // A head with no checks. Only a repository with no CI at all has nothing to
89
+ // gate on; in one with CI the head is not yet, whatever the reason.
90
+ function noChecks(i: GateInput, at: string): GateOutcome {
91
+ const gate = "ci";
92
+ const wait = (reason: string): GateOutcome => ({
93
+ gate,
94
+ state: "wait",
95
+ reason,
96
+ });
97
+ if (i.mergeable === "CONFLICTING")
98
+ return wait(
99
+ `PR conflicts with ${i.baseName ?? "its base"}; CI can't run on ${at} until it's rebased`
100
+ );
101
+ if (i.settleIn > 0)
102
+ return wait(
103
+ `no CI checks have registered on ${at} yet (looking again in ${i.settleIn}s)`
104
+ );
105
+ if (i.repoCI === "no")
81
106
  return {
82
107
  gate,
83
108
  state: "escalate",
84
- reason: `no CI ran on ${at}: this repository has no checks to gate a merge on`,
109
+ reason: `no CI ran on ${at}: this repository has no checks to gate a merge on (no workflow files, and no check has run on its default branch or recent PRs)`,
85
110
  };
86
- return { gate, state: "pass" };
111
+ if (i.repoCI === "yes")
112
+ return wait(
113
+ i.mergeable === "UNKNOWN"
114
+ ? `no CI checks on ${at} yet: GitHub is still working out whether it merges cleanly`
115
+ : `no CI checks on ${at} yet: CI hasn't been triggered on this head`
116
+ );
117
+ return wait(
118
+ `no CI checks on ${at}, and whether this repository has CI couldn't be read; looking again`
119
+ );
87
120
  }
88
121
 
89
122
  // A stored check of this exact commit: missing, running or broken waits.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Whether a repository has CI at all, decided from the repository and never
3
+ * from one head: workflow files on the base, else any check or status that
4
+ * ever ran on the default branch or the recent PRs. A head with no checks in
5
+ * a repository that has CI is not yet (its PR conflicts, or CI hasn't been
6
+ * triggered); only a repository without CI is one with nothing to gate on.
7
+ */
8
+
9
+ import { getDefaultBranch } from "../git";
10
+ import { git } from "../git-exec";
11
+ import { run } from "../tasks/gh";
12
+ import { repoSlug } from "./repo-slug";
13
+
14
+ export type RepoCI = "yes" | "no" | "unknown";
15
+
16
+ const YES_TTL_MS = 60 * 60 * 1000;
17
+ const NO_TTL_MS = 10 * 60 * 1000;
18
+ const cache = new Map<string, { at: number; ci: "yes" | "no" }>();
19
+
20
+ export function clearRepoCI(): void {
21
+ cache.clear();
22
+ }
23
+
24
+ export interface RepoCIDeps {
25
+ // The files under .github/workflows on `ref`.
26
+ workflows: (repo: string, ref: string) => Promise<string[]>;
27
+ // How many checks or statuses ran on the default branch and the recent
28
+ // PRs; null when GitHub can't say.
29
+ history: (repo: string) => Promise<number | null>;
30
+ now: () => number;
31
+ }
32
+
33
+ const count = (out: string) => {
34
+ const n = Number(out.trim());
35
+ return out.trim() !== "" && Number.isInteger(n) && n >= 0 ? n : null;
36
+ };
37
+
38
+ export const repoCIDeps: RepoCIDeps = {
39
+ workflows: async (repo, ref) =>
40
+ (
41
+ await git(repo, ["ls-tree", "--name-only", ref, ".github/workflows/"], {
42
+ timeout: 10000,
43
+ })
44
+ )
45
+ .split("\n")
46
+ .filter((f) => /\.ya?ml$/i.test(f.trim())),
47
+ history: async (repo) => {
48
+ const slug = await repoSlug(repo);
49
+ if (!slug) return null;
50
+ const branch = await getDefaultBranch(repo);
51
+ const ask = (args: string[]) =>
52
+ run("gh", args, repo, 20000).then(count, () => null);
53
+ const counts = await Promise.all([
54
+ ask([
55
+ "api",
56
+ `repos/${slug}/commits/${branch}/check-runs?per_page=1`,
57
+ "--jq",
58
+ ".total_count",
59
+ ]),
60
+ ask([
61
+ "api",
62
+ `repos/${slug}/commits/${branch}/status`,
63
+ "--jq",
64
+ ".statuses | length",
65
+ ]),
66
+ ask([
67
+ "pr",
68
+ "list",
69
+ "--state",
70
+ "all",
71
+ "--limit",
72
+ "20",
73
+ "--json",
74
+ "statusCheckRollup",
75
+ "--jq",
76
+ "[.[] | (.statusCheckRollup // []) | length] | add // 0",
77
+ ]),
78
+ ]);
79
+ if (counts.some((c) => c !== null && c > 0)) return 1;
80
+ return counts.every((c) => c === 0) ? 0 : null;
81
+ },
82
+ now: Date.now,
83
+ };
84
+
85
+ // "unknown" when it can't be told (GitHub unreachable or backed off): the
86
+ // ci gate waits on that, it never escalates or passes on a guess.
87
+ export async function repoHasCI(
88
+ repo: string,
89
+ baseRef: string,
90
+ deps: RepoCIDeps = repoCIDeps
91
+ ): Promise<RepoCI> {
92
+ const hit = cache.get(repo);
93
+ if (hit && deps.now() - hit.at < (hit.ci === "yes" ? YES_TTL_MS : NO_TTL_MS))
94
+ return hit.ci;
95
+ const found = (ci: "yes" | "no") => {
96
+ cache.set(repo, { at: deps.now(), ci });
97
+ return ci;
98
+ };
99
+ if ((await deps.workflows(repo, baseRef).catch(() => [])).length)
100
+ return found("yes");
101
+ const seen = await deps.history(repo).catch(() => null);
102
+ if (seen === null) return "unknown";
103
+ return found(seen > 0 ? "yes" : "no");
104
+ }
@@ -3,8 +3,9 @@
3
3
  * commit that was judged. With merge approvals on (merge-approvals.ts),
4
4
  * anything touching CI config, build and hook scripts, agent config, deploys
5
5
  * or secrets handling goes to Saad whatever the gates say. A repository with
6
- * no CI and a gate failing on two different commits go to him either way. Once a task
7
- * is with Saad the orchestrator doesn't merge it; he signs it off or drops it.
6
+ * no CI (decided from the repository, never from one head with no checks)
7
+ * and a gate failing on two different commits go to him either way. Once a
8
+ * task is with Saad the orchestrator doesn't merge it; he signs it off or drops it.
8
9
  */
9
10
 
10
11
  import { MERGED_AS, type MergeMethod } from "../tasks/merge-policy";
@@ -32,6 +33,7 @@ import { heldVerdict } from "./held";
32
33
  import { mergeApprovalsOn } from "./merge-approvals";
33
34
  import { isPaused } from "./pause";
34
35
  import { commitTime, fetchRefs, repoOf } from "./repo";
36
+ import { repoHasCI } from "./repo-ci";
35
37
  import { DIFF_CAP, review } from "./review";
36
38
  import { workspaceStack, workspaceTask } from "./targets";
37
39
  import { ciSettleIn, waitingState } from "./task-state";
@@ -112,6 +114,8 @@ export async function judge(
112
114
  checkCount: pr.checkCount ?? 0,
113
115
  committedAt: await commitTime(repo, sha),
114
116
  }),
117
+ mergeable: pr.mergeable,
118
+ baseName: pr.base ?? task.base_branch,
115
119
  review: getCheck(task.id, sha, "review"),
116
120
  ...(await waitingState(task)),
117
121
  fromCard: !!task.lh_card_id,
@@ -203,11 +207,17 @@ export async function gateVerdict(
203
207
  }
204
208
  }
205
209
 
206
- const outcomes = evaluateGates({
207
- sha,
208
- ruleBreaks: ruleBreaks(files, await addedLines(repo, base, sha)),
209
- ...(await inputs()),
210
- });
210
+ const ruled = ruleBreaks(files, await addedLines(repo, base, sha));
211
+ const live = await inputs();
212
+ // Asked only when it can change the answer: a head with no checks that
213
+ // isn't settling or conflicting.
214
+ const repoCI =
215
+ live.checks === "none" &&
216
+ live.settleIn === 0 &&
217
+ live.mergeable !== "CONFLICTING"
218
+ ? await repoHasCI(repo, base)
219
+ : undefined;
220
+ const outcomes = evaluateGates({ sha, ruleBreaks: ruled, ...live, repoCI });
211
221
  const head = `${work.name} (PR #${pr.number} at ${short(sha)}) can't merge:`;
212
222
  const toSaad = outcomes.find((o) => o.state === "escalate");
213
223
  if (toSaad)
@@ -2,6 +2,7 @@
2
2
  // live inputs to the blocked and ci gates.
3
3
 
4
4
  import type { Session } from "../db";
5
+ import type { TaskPR } from "../tasks/state";
5
6
  import { chatStateNow } from "../chat/runner";
6
7
  import { saidSinceLastMessage } from "../chat/store";
7
8
  import { blockedReason } from "../tasks/state";
@@ -139,3 +140,33 @@ export function ciSettleIn(input: {
139
140
  const settledAt = Math.max(input.committedAt, changedAt) + SETTLE_S;
140
141
  return Math.max(0, settledAt - now);
141
142
  }
143
+
144
+ // A head with no checks for this long after its PR became mergeable, in a
145
+ // repository with CI, is news for the orchestrator (never an escalation).
146
+ export const CHECKLESS_MS = 30 * 60 * 1000;
147
+
148
+ const checkless = new Map<string, number>();
149
+
150
+ // How long this head has had no checks while its PR could merge cleanly, in
151
+ // ms, or undefined when it isn't in that state. In memory: a restart only
152
+ // restarts the clock.
153
+ export function checklessFor(
154
+ taskId: string,
155
+ pr: Pick<TaskPR, "state" | "checks" | "head" | "mergeable">,
156
+ now = Date.now()
157
+ ): number | undefined {
158
+ const key = `${taskId}:${pr.head ?? ""}`;
159
+ for (const k of checkless.keys())
160
+ if (k.startsWith(`${taskId}:`) && k !== key) checkless.delete(k);
161
+ if (
162
+ pr.state !== "OPEN" ||
163
+ pr.checks !== "none" ||
164
+ pr.mergeable !== "MERGEABLE"
165
+ ) {
166
+ checkless.delete(key);
167
+ return undefined;
168
+ }
169
+ const since = checkless.get(key) ?? now;
170
+ checkless.set(key, since);
171
+ return now - since;
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.484",
3
+ "version": "0.3.485",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"