@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.
- package/lib/orchestrator/armed.ts +1 -0
- package/lib/orchestrator/conditions.ts +7 -0
- package/lib/orchestrator/describe.ts +3 -1
- package/lib/orchestrator/external-gates.ts +2 -0
- package/lib/orchestrator/external-pr.ts +1 -1
- package/lib/orchestrator/facts.ts +20 -1
- package/lib/orchestrator/gates.ts +42 -9
- package/lib/orchestrator/repo-ci.ts +104 -0
- package/lib/orchestrator/signoff.ts +17 -7
- package/lib/orchestrator/task-state.ts +31 -0
- package/package.json +1 -1
|
@@ -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
|
|
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);
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7
|
-
*
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|