@saadnvd1/agent-os 0.3.459 → 0.3.461
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/README.md +53 -1
- package/app/api/away/route.ts +56 -0
- package/app/api/away/seen/route.ts +12 -0
- package/app/api/health/route.ts +9 -0
- package/app/api/workspaces/[id]/route.ts +8 -1
- package/app/page.tsx +2 -0
- package/components/Away/AwayReport.tsx +16 -0
- package/components/Away/AwayReportDialog.tsx +193 -0
- package/components/Away/AwayToggle.tsx +91 -0
- package/components/Away/time.test.ts +20 -0
- package/components/Away/time.ts +32 -0
- package/components/Away/useAwayCommands.ts +43 -0
- package/components/Chat/Messages.tsx +1 -0
- package/components/CommandPalette/useAppCommands.ts +2 -0
- package/components/Pane/MobileTabBar.tsx +14 -1
- package/components/SessionList/SidebarHeader.tsx +2 -0
- package/components/Workspaces/WorkspaceSettings.tsx +9 -1
- package/data/away/index.ts +73 -0
- package/data/push/topics.ts +4 -0
- package/data/workspaces/index.ts +6 -1
- package/lib/away/brief.ts +23 -0
- package/lib/away/deploy.ts +173 -0
- package/lib/away/report.ts +398 -0
- package/lib/away/run.ts +445 -0
- package/lib/away/state.ts +201 -0
- package/lib/away/sweep.ts +216 -0
- package/lib/away/view.ts +51 -0
- package/lib/chat/events.ts +1 -1
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +101 -0
- package/lib/db/types.ts +9 -0
- package/lib/notify/index.ts +7 -0
- package/lib/orchestrator/brief.ts +2 -0
- package/lib/orchestrator/conditions.ts +10 -3
- package/lib/orchestrator/digest.ts +11 -8
- package/lib/orchestrator/notes.ts +8 -1
- package/lib/orchestrator/pr-wake.ts +385 -0
- package/lib/orchestrator/watcher.ts +12 -2
- package/lib/project-config/agentos.schema.json +24 -0
- package/lib/project-config/schema.ts +17 -0
- package/lib/tasks/gh.ts +2 -0
- package/lib/tasks/pr-activity.ts +295 -0
- package/lib/tasks/state.ts +9 -1
- package/lib/workspaces.ts +8 -2
- package/package.json +1 -1
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wakes a task's own agent when its PR needs it, so the orchestrator
|
|
3
|
+
* doesn't have to pass the news on. Runs in the watcher's merge pipeline,
|
|
4
|
+
* off the PR poll AgentOS already makes (no poller of its own):
|
|
5
|
+
*
|
|
6
|
+
* - A failing check on the PR's head: the task hears it once per head,
|
|
7
|
+
* with the check's name, the failing step and the log around the error
|
|
8
|
+
* (`gh run view --log-failed`, trimmed). Read from the rollup the poll
|
|
9
|
+
* already has, so only the log costs a call.
|
|
10
|
+
* - A review or conversation comment from a collaborator other than the
|
|
11
|
+
* account gh runs as (which is the task's own): one GraphQL query per
|
|
12
|
+
* repository a minute fingerprints every watched PR, and a PR's reviews
|
|
13
|
+
* and comments are read only when its fingerprint moved. Comment-only
|
|
14
|
+
* wakes stop at COMMENT_WAKE_CAP a task; changes requested always wake.
|
|
15
|
+
*
|
|
16
|
+
* Each event is claimed in task_pr_wakes before its message goes, so it's
|
|
17
|
+
* sent once across restarts. Messages go through the bus like any other:
|
|
18
|
+
* a task mid-turn gets it queued, never interrupted. The orchestrator hears
|
|
19
|
+
* one line per wake. Only open PRs of running tasks are watched: merged,
|
|
20
|
+
* closed, dropped or done stops it. Off per workspace in Settings.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { db, type Session } from "../db";
|
|
24
|
+
import { sendMessage } from "../bus";
|
|
25
|
+
import { getWorkspace } from "../workspaces";
|
|
26
|
+
import { OPEN_TTL_MS } from "../tasks/pr-poll";
|
|
27
|
+
import {
|
|
28
|
+
failedLog,
|
|
29
|
+
fingerprints,
|
|
30
|
+
prActivity,
|
|
31
|
+
repoOfPR,
|
|
32
|
+
type FailedLog,
|
|
33
|
+
type Fingerprint,
|
|
34
|
+
type PREvent,
|
|
35
|
+
type Repo,
|
|
36
|
+
} from "../tasks/pr-activity";
|
|
37
|
+
import type { TaskPR } from "../tasks/state";
|
|
38
|
+
import { ciKey, conditionKey } from "./conditions";
|
|
39
|
+
import { markTold, queueEvent } from "./events";
|
|
40
|
+
import type { SessionFacts } from "./facts";
|
|
41
|
+
import { isPaused } from "./pause";
|
|
42
|
+
import { repoOf } from "./repo";
|
|
43
|
+
import { untrusted, UNTRUSTED_RULE } from "./untrusted";
|
|
44
|
+
|
|
45
|
+
export const COMMENT_WAKE_CAP = 10;
|
|
46
|
+
// A first look at a PR this young takes its whole history as new.
|
|
47
|
+
const YOUNG_PR_MS = 10 * 60_000;
|
|
48
|
+
// Collaborators only: anyone can comment on a public repository's PR.
|
|
49
|
+
const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
50
|
+
const BODY_MAX = 800;
|
|
51
|
+
const NOTE_MAX = 300;
|
|
52
|
+
const NOTES_MAX = 10;
|
|
53
|
+
const SEND_TRIES = 3;
|
|
54
|
+
|
|
55
|
+
export interface WakeDeps {
|
|
56
|
+
fingerprints: typeof fingerprints;
|
|
57
|
+
activity: typeof prActivity;
|
|
58
|
+
failedLog: typeof failedLog;
|
|
59
|
+
// Message the task's agent through the bus; throws when it failed.
|
|
60
|
+
send: (task: Session, body: string) => Promise<unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// As an AgentOS event, through the bus: queued while the agent is mid-turn.
|
|
64
|
+
export async function sendWake(task: Session, body: string) {
|
|
65
|
+
const { delivery } = await sendMessage({
|
|
66
|
+
fromId: null,
|
|
67
|
+
to: task.id,
|
|
68
|
+
body,
|
|
69
|
+
fromLabel: "AgentOS",
|
|
70
|
+
origin: { kind: "event", label: "AgentOS", body },
|
|
71
|
+
// A chat agent reads it whole; a terminal's is told to read its inbox.
|
|
72
|
+
line: task.view === "chat" ? `[AgentOS event] ${body}` : undefined,
|
|
73
|
+
});
|
|
74
|
+
if (delivery.state === "failed") throw new Error(delivery.why);
|
|
75
|
+
return delivery.state;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const defaults: WakeDeps = {
|
|
79
|
+
fingerprints,
|
|
80
|
+
activity: prActivity,
|
|
81
|
+
failedLog,
|
|
82
|
+
send: sendWake,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const short = (sha: string) => sha.slice(0, 7);
|
|
86
|
+
const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
87
|
+
|
|
88
|
+
// The fingerprint each task's PR was last handled at, and when each
|
|
89
|
+
// repository was last fingerprinted.
|
|
90
|
+
const handled = new Map<string, string>();
|
|
91
|
+
const polled = new Map<string, number>();
|
|
92
|
+
// Sends that failed, per task and key, so a broken one stops retrying.
|
|
93
|
+
const failures = new Map<string, number>();
|
|
94
|
+
|
|
95
|
+
const sessionRow = (id: string) =>
|
|
96
|
+
db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
|
|
97
|
+
| Session
|
|
98
|
+
| undefined;
|
|
99
|
+
|
|
100
|
+
// True when this call took the key; false when it was taken before.
|
|
101
|
+
const claim = (task: string, key: string, kind: string) =>
|
|
102
|
+
db
|
|
103
|
+
.prepare(
|
|
104
|
+
`INSERT OR IGNORE INTO task_pr_wakes (session_id, key, kind) VALUES (?, ?, ?)`
|
|
105
|
+
)
|
|
106
|
+
.run(task, key, kind).changes > 0;
|
|
107
|
+
|
|
108
|
+
const taken = (task: string, key: string) =>
|
|
109
|
+
!!db
|
|
110
|
+
.prepare(`SELECT 1 FROM task_pr_wakes WHERE session_id = ? AND key = ?`)
|
|
111
|
+
.get(task, key);
|
|
112
|
+
|
|
113
|
+
const unclaim = (task: string, keys: string[]) => {
|
|
114
|
+
const del = db.prepare(
|
|
115
|
+
`DELETE FROM task_pr_wakes WHERE session_id = ? AND key = ?`
|
|
116
|
+
);
|
|
117
|
+
for (const key of keys) del.run(task, key);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const commentWakes = (task: string) =>
|
|
121
|
+
(
|
|
122
|
+
db
|
|
123
|
+
.prepare(
|
|
124
|
+
`SELECT COUNT(*) AS n FROM task_pr_wakes WHERE session_id = ? AND kind = 'comment'`
|
|
125
|
+
)
|
|
126
|
+
.get(task) as { n: number }
|
|
127
|
+
).n;
|
|
128
|
+
|
|
129
|
+
interface Watched {
|
|
130
|
+
task: Session;
|
|
131
|
+
pr: TaskPR & { head: string };
|
|
132
|
+
repo: Repo;
|
|
133
|
+
dir: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function wakeTasks(
|
|
137
|
+
workspaceId: string,
|
|
138
|
+
facts: SessionFacts[],
|
|
139
|
+
deps: WakeDeps = defaults,
|
|
140
|
+
now = Date.now()
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
if (!getWorkspace(workspaceId)?.wake_tasks_on_pr || isPaused(workspaceId))
|
|
143
|
+
return;
|
|
144
|
+
const watched: Watched[] = [];
|
|
145
|
+
for (const f of facts) {
|
|
146
|
+
const pr = f.task?.pr;
|
|
147
|
+
const task = pr?.state === "OPEN" && pr.head ? sessionRow(f.id) : null;
|
|
148
|
+
const repo = pr && repoOfPR(pr.url);
|
|
149
|
+
if (!task || task.task_status !== "running" || !repo || !pr?.head) {
|
|
150
|
+
handled.delete(f.id);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
let dir: string;
|
|
154
|
+
try {
|
|
155
|
+
dir = repoOf(task);
|
|
156
|
+
} catch {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
watched.push({ task, pr: { ...pr, head: pr.head }, repo, dir });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const w of watched) {
|
|
163
|
+
if (isPaused(workspaceId)) return;
|
|
164
|
+
if (w.pr.checks !== "fail" || !w.pr.failing) continue;
|
|
165
|
+
await wakeOnCheck(workspaceId, w, deps).catch((error) =>
|
|
166
|
+
console.error(`Waking ${w.task.name} on CI:`, error)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const byRepo = new Map<string, Watched[]>();
|
|
171
|
+
for (const w of watched) {
|
|
172
|
+
const slug = `${w.repo.owner}/${w.repo.name}`;
|
|
173
|
+
byRepo.set(slug, [...(byRepo.get(slug) ?? []), w]);
|
|
174
|
+
}
|
|
175
|
+
for (const [slug, group] of byRepo) {
|
|
176
|
+
if (isPaused(workspaceId)) return;
|
|
177
|
+
if (now - (polled.get(slug) ?? 0) < OPEN_TTL_MS) continue;
|
|
178
|
+
polled.set(slug, now);
|
|
179
|
+
let prints: Map<number, Fingerprint>;
|
|
180
|
+
try {
|
|
181
|
+
prints = await deps.fingerprints(
|
|
182
|
+
group[0].dir,
|
|
183
|
+
group[0].repo,
|
|
184
|
+
group.map((w) => w.pr.number)
|
|
185
|
+
);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error(`Fingerprinting PRs in ${slug}:`, error);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
for (const w of group) {
|
|
191
|
+
const print = prints.get(w.pr.number);
|
|
192
|
+
if (!print || handled.get(w.task.id) === print.print) continue;
|
|
193
|
+
try {
|
|
194
|
+
if (await wakeOnReviews(workspaceId, w, print, deps, now))
|
|
195
|
+
handled.set(w.task.id, print.print);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
console.error(`Waking ${w.task.name} on reviews:`, error);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Whether the task is still to be woken now, after the gh calls since it
|
|
204
|
+
// was read: not paused or turned off, and the task still running.
|
|
205
|
+
const stillWatched = (workspaceId: string, taskId: string) =>
|
|
206
|
+
!isPaused(workspaceId) &&
|
|
207
|
+
!!getWorkspace(workspaceId)?.wake_tasks_on_pr &&
|
|
208
|
+
sessionRow(taskId)?.task_status === "running";
|
|
209
|
+
|
|
210
|
+
// Sends after claiming `keys`; a failed send gives them back for the next
|
|
211
|
+
// poll, SEND_TRIES times. False when it didn't go.
|
|
212
|
+
async function wake(
|
|
213
|
+
workspaceId: string,
|
|
214
|
+
w: Watched,
|
|
215
|
+
keys: string[],
|
|
216
|
+
body: string,
|
|
217
|
+
deps: WakeDeps
|
|
218
|
+
): Promise<boolean> {
|
|
219
|
+
if (!stillWatched(workspaceId, w.task.id)) {
|
|
220
|
+
unclaim(w.task.id, keys);
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
await deps.send(w.task, body);
|
|
225
|
+
return true;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const id = `${w.task.id}\0${keys[0]}`;
|
|
228
|
+
const n = (failures.get(id) ?? 0) + 1;
|
|
229
|
+
failures.set(id, n);
|
|
230
|
+
console.error(`Messaging ${w.task.name} (try ${n}):`, error);
|
|
231
|
+
if (n < SEND_TRIES) unclaim(w.task.id, keys);
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function wakeOnCheck(
|
|
237
|
+
workspaceId: string,
|
|
238
|
+
w: Watched,
|
|
239
|
+
deps: WakeDeps
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const { task, pr } = w;
|
|
242
|
+
const key = `check:${pr.head}`;
|
|
243
|
+
if (taken(task.id, key)) return;
|
|
244
|
+
const log: FailedLog | null = await deps
|
|
245
|
+
.failedLog(w.dir, w.repo, pr.failingUrl)
|
|
246
|
+
.catch((error: unknown) => {
|
|
247
|
+
console.error(`Reading ${task.name}'s CI log:`, error);
|
|
248
|
+
return null;
|
|
249
|
+
});
|
|
250
|
+
// Claimed only now, next to the send, so a restart while the log is
|
|
251
|
+
// read doesn't lose the wake.
|
|
252
|
+
if (!claim(task.id, key, "check")) return;
|
|
253
|
+
if (!(await wake(workspaceId, w, [key], checkMessage(pr, log), deps))) return;
|
|
254
|
+
queueEvent(
|
|
255
|
+
workspaceId,
|
|
256
|
+
`woke:${task.id}:${key}`,
|
|
257
|
+
task.id,
|
|
258
|
+
`task ${task.name} woken: CI ${untrusted("CI", pr.failing!)} failed at ${short(pr.head)}`
|
|
259
|
+
);
|
|
260
|
+
// The orchestrator's own "CI failed" line for this head would only
|
|
261
|
+
// repeat it.
|
|
262
|
+
markTold(workspaceId, conditionKey(ciKey(pr), task.id), task.id);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function checkMessage(
|
|
266
|
+
pr: TaskPR & { head: string },
|
|
267
|
+
log: FailedLog | null
|
|
268
|
+
): string {
|
|
269
|
+
const step = log?.step ? ` at step ${untrusted("CI", log.step)}` : "";
|
|
270
|
+
const tail = log?.tail
|
|
271
|
+
? `\n\nThe log around the error:\n${untrusted("CI log", log.tail)}`
|
|
272
|
+
: pr.failingUrl
|
|
273
|
+
? `\n\nIts details: ${untrusted("CI", pr.failingUrl)}`
|
|
274
|
+
: "";
|
|
275
|
+
return `CI check ${untrusted("CI", pr.failing ?? "")} failed on your PR #${pr.number} at ${short(pr.head)}${step}.${tail}\n\n${UNTRUSTED_RULE}\n\nFix the root cause (not the check), re-run your checks, push, then run /do-code-review again and update the Reviewed: line in the PR body for the new head.`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// True once the PR's activity at this fingerprint has been handled.
|
|
279
|
+
async function wakeOnReviews(
|
|
280
|
+
workspaceId: string,
|
|
281
|
+
w: Watched,
|
|
282
|
+
print: Fingerprint,
|
|
283
|
+
deps: WakeDeps,
|
|
284
|
+
now: number
|
|
285
|
+
): Promise<boolean> {
|
|
286
|
+
const { task, pr } = w;
|
|
287
|
+
const since = Date.parse(watchSince(task, print, now));
|
|
288
|
+
const activity = await deps.activity(w.dir, w.repo, pr.number);
|
|
289
|
+
const fresh = activity.events.filter(
|
|
290
|
+
(e) =>
|
|
291
|
+
e.author !== activity.viewer &&
|
|
292
|
+
TRUSTED.has(e.association) &&
|
|
293
|
+
Date.parse(e.at) >= since &&
|
|
294
|
+
worthWaking(e) &&
|
|
295
|
+
!taken(task.id, `event:${e.id}`)
|
|
296
|
+
);
|
|
297
|
+
if (!fresh.length) return true;
|
|
298
|
+
const changes = fresh.some((e) => e.state === "CHANGES_REQUESTED");
|
|
299
|
+
const kind = changes ? "review" : "comment";
|
|
300
|
+
const keys = fresh.map((e) => `event:${e.id}`);
|
|
301
|
+
if (!changes && commentWakes(task.id) >= COMMENT_WAKE_CAP) {
|
|
302
|
+
for (const key of keys) claim(task.id, key, "event");
|
|
303
|
+
if (claim(task.id, "capped", "capped"))
|
|
304
|
+
queueEvent(
|
|
305
|
+
workspaceId,
|
|
306
|
+
`woke:${task.id}:capped`,
|
|
307
|
+
task.id,
|
|
308
|
+
`task ${task.name}: ${COMMENT_WAKE_CAP} comment wakes on PR #${pr.number} reached; further comments won't wake it`
|
|
309
|
+
);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
const wakeKey = `wake:${fresh[0].id}`;
|
|
313
|
+
const claimed = [wakeKey, ...keys].filter((key, i) =>
|
|
314
|
+
claim(task.id, key, i === 0 ? kind : "event")
|
|
315
|
+
);
|
|
316
|
+
if (!claimed.includes(wakeKey)) return true;
|
|
317
|
+
if (!(await wake(workspaceId, w, claimed, reviewMessage(pr, fresh), deps)))
|
|
318
|
+
return false;
|
|
319
|
+
const who = [...new Set(fresh.map((e) => e.author))].join(", ");
|
|
320
|
+
queueEvent(
|
|
321
|
+
workspaceId,
|
|
322
|
+
`woke:${task.id}:${wakeKey}`,
|
|
323
|
+
task.id,
|
|
324
|
+
`task ${task.name} woken: ${changes ? "changes requested" : fresh.length === 1 ? "a review comment" : `${fresh.length} review comments`} on PR #${pr.number} from ${who}`
|
|
325
|
+
);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// An approval or a dismissed review with nothing to say asks for nothing.
|
|
330
|
+
const worthWaking = (e: PREvent) =>
|
|
331
|
+
e.state !== "PENDING" &&
|
|
332
|
+
e.state !== "DISMISSED" &&
|
|
333
|
+
(e.state === "CHANGES_REQUESTED" || !!e.body.trim() || e.notes.length > 0);
|
|
334
|
+
|
|
335
|
+
// When this PR's reviews started being watched, set on the first look:
|
|
336
|
+
// a young PR from its start, an older one from now, so what was said
|
|
337
|
+
// before this watched it isn't news.
|
|
338
|
+
function watchSince(task: Session, print: Fingerprint, now: number): string {
|
|
339
|
+
if (task.pr_wake_since) return task.pr_wake_since;
|
|
340
|
+
const created = Date.parse(print.createdAt);
|
|
341
|
+
const since =
|
|
342
|
+
Number.isFinite(created) && now - created < YOUNG_PR_MS
|
|
343
|
+
? new Date(created).toISOString()
|
|
344
|
+
: new Date(now).toISOString();
|
|
345
|
+
db.prepare(`UPDATE sessions SET pr_wake_since = ? WHERE id = ?`).run(
|
|
346
|
+
since,
|
|
347
|
+
task.id
|
|
348
|
+
);
|
|
349
|
+
task.pr_wake_since = since;
|
|
350
|
+
return since;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function reviewMessage(pr: TaskPR, events: PREvent[]): string {
|
|
354
|
+
const items = events.map((e) => {
|
|
355
|
+
const what =
|
|
356
|
+
e.state === "CHANGES_REQUESTED"
|
|
357
|
+
? "requested changes"
|
|
358
|
+
: e.kind === "review"
|
|
359
|
+
? "reviewed"
|
|
360
|
+
: "commented";
|
|
361
|
+
const body = e.body.trim()
|
|
362
|
+
? `: ${untrusted(e.author, clip(e.body.trim(), BODY_MAX))}`
|
|
363
|
+
: "";
|
|
364
|
+
const notes = e.notes
|
|
365
|
+
.slice(0, NOTES_MAX)
|
|
366
|
+
.map(
|
|
367
|
+
(n) =>
|
|
368
|
+
`\n - ${n.path}${n.line ? `:${n.line}` : ""}: ${untrusted(e.author, clip(n.body.trim(), NOTE_MAX))}`
|
|
369
|
+
)
|
|
370
|
+
.join("");
|
|
371
|
+
const more =
|
|
372
|
+
e.notes.length > NOTES_MAX
|
|
373
|
+
? `\n - and ${e.notes.length - NOTES_MAX} more on the PR`
|
|
374
|
+
: "";
|
|
375
|
+
return `- ${e.author} ${what}${body}${notes}${more}`;
|
|
376
|
+
});
|
|
377
|
+
return `New review activity on your PR #${pr.number}:\n${items.join("\n")}\n\n${UNTRUSTED_RULE} Address each point: fix what's right, re-run your checks, push, then run /do-code-review again and update the Reviewed: line for the new head. Where you disagree, reply on the PR (\`gh pr comment ${pr.number}\`) saying why.`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Tests only.
|
|
381
|
+
export function resetPRWake(): void {
|
|
382
|
+
handled.clear();
|
|
383
|
+
polled.clear();
|
|
384
|
+
failures.clear();
|
|
385
|
+
}
|
|
@@ -17,6 +17,8 @@ import { settleStaleAsks } from "./ask-settle";
|
|
|
17
17
|
import { brakesOn, liftBrake } from "./brakes";
|
|
18
18
|
import { anyArmed, mergeArmed } from "./armed";
|
|
19
19
|
import { rebaseConflicting } from "./rebase";
|
|
20
|
+
import { wakeTasks } from "./pr-wake";
|
|
21
|
+
import { awayTick } from "../away/run";
|
|
20
22
|
|
|
21
23
|
// Asks that stopped needing Saad: their task or PR finished some other
|
|
22
24
|
// way, or brakes lifted with nothing trying to start.
|
|
@@ -61,8 +63,8 @@ export async function diffWorkspace(
|
|
|
61
63
|
return nextDiffAt(now, facts, working);
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
//
|
|
65
|
-
// passes. In the background, one run per workspace at a time, so a git
|
|
66
|
+
// Wakes tasks whose PRs failed CI or got reviews, rebases what conflicts
|
|
67
|
+
// with its base, then merges what's armed and passes. In the background, one run per workspace at a time, so a git
|
|
66
68
|
// fetch or a merge never holds up event delivery.
|
|
67
69
|
const piping = new Set<string>();
|
|
68
70
|
|
|
@@ -70,6 +72,9 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
|
|
|
70
72
|
if (piping.has(workspaceId)) return;
|
|
71
73
|
piping.add(workspaceId);
|
|
72
74
|
void (async () => {
|
|
75
|
+
await wakeTasks(workspaceId, facts).catch((error) =>
|
|
76
|
+
console.error(`Waking tasks in ${workspaceId}:`, error)
|
|
77
|
+
);
|
|
73
78
|
await rebaseConflicting(workspaceId, facts);
|
|
74
79
|
await mergeArmed(workspaceId, facts);
|
|
75
80
|
})()
|
|
@@ -97,6 +102,11 @@ async function tick(): Promise<void> {
|
|
|
97
102
|
if (busy) return;
|
|
98
103
|
busy = true;
|
|
99
104
|
try {
|
|
105
|
+
try {
|
|
106
|
+
awayTick();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error("Away mode:", error);
|
|
109
|
+
}
|
|
100
110
|
for (const orch of listOrchestrators()) {
|
|
101
111
|
const workspaceId = orch.workspace_id;
|
|
102
112
|
if (!workspaceId || !getWorkspace(workspaceId)) continue;
|
|
@@ -73,6 +73,30 @@
|
|
|
73
73
|
},
|
|
74
74
|
"additionalProperties": false
|
|
75
75
|
},
|
|
76
|
+
"deploy": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": {
|
|
79
|
+
"deployed": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"minLength": 1
|
|
82
|
+
},
|
|
83
|
+
"failed": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"minLength": 1
|
|
86
|
+
},
|
|
87
|
+
"health": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"minLength": 1
|
|
90
|
+
},
|
|
91
|
+
"within_minutes": {
|
|
92
|
+
"type": "integer",
|
|
93
|
+
"minimum": 1,
|
|
94
|
+
"maximum": 1440
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"required": ["deployed"],
|
|
98
|
+
"additionalProperties": false
|
|
99
|
+
},
|
|
76
100
|
"test": {
|
|
77
101
|
"type": "string",
|
|
78
102
|
"minLength": 1
|
|
@@ -38,6 +38,22 @@ const ready = z.strictObject({
|
|
|
38
38
|
contains: z.string().optional(),
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
// How Away mode checks a merge went live (lib/away/deploy.ts): a file the
|
|
42
|
+
// deploy writes the deployed commit to, one it writes a commit whose deploy
|
|
43
|
+
// failed to, and a URL that answers 2xx when the server is healthy (a path
|
|
44
|
+
// alone is this AgentOS server's own).
|
|
45
|
+
const deploy = z.strictObject({
|
|
46
|
+
deployed: z.string().min(1),
|
|
47
|
+
failed: z.string().min(1).optional(),
|
|
48
|
+
health: z.string().min(1).optional(),
|
|
49
|
+
within_minutes: z
|
|
50
|
+
.number()
|
|
51
|
+
.int()
|
|
52
|
+
.min(1)
|
|
53
|
+
.max(24 * 60)
|
|
54
|
+
.optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
41
57
|
const browse = z.strictObject({
|
|
42
58
|
login: z.string().min(1).optional(),
|
|
43
59
|
map: projectPath.optional(),
|
|
@@ -89,6 +105,7 @@ const shape = {
|
|
|
89
105
|
clone: z.array(projectPath).optional(),
|
|
90
106
|
dev: z.string().min(1).optional(),
|
|
91
107
|
ready: ready.optional(),
|
|
108
|
+
deploy: deploy.optional(),
|
|
92
109
|
test: z.string().min(1).optional(),
|
|
93
110
|
notes: notes.optional(),
|
|
94
111
|
browse: browse.optional(),
|
package/lib/tasks/gh.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { assertGhAllowed, noteGhFailure, noteGhSuccess } from "./gh-limit";
|
|
|
6
6
|
import {
|
|
7
7
|
checksVerdict,
|
|
8
8
|
failingCheck,
|
|
9
|
+
failingUrl,
|
|
9
10
|
rerunsToLookUp,
|
|
10
11
|
settleReruns,
|
|
11
12
|
type ActionsRun,
|
|
@@ -106,6 +107,7 @@ export async function toTaskPR(repoDir: string, pr: ListedPR): Promise<TaskPR> {
|
|
|
106
107
|
checks: checksVerdict(rollup),
|
|
107
108
|
head: pr.headRefOid,
|
|
108
109
|
failing: failingCheck(rollup),
|
|
110
|
+
failingUrl: failingUrl(rollup),
|
|
109
111
|
checkCount: (pr.statusCheckRollup ?? []).length,
|
|
110
112
|
codeReview: pr.body === undefined ? undefined : parseCodeReview(pr.body),
|
|
111
113
|
scopeChange: parseScopeChange(pr.body),
|