@saadnvd1/agent-os 0.3.459 → 0.3.460

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 CHANGED
@@ -900,6 +900,18 @@ it may do on its own, and the lines that always come back to you as asks.
900
900
  unchanged (same `git patch-id`; a fresh review starts otherwise). Not
901
901
  clean: the task is asked once to rebase. Either way, one event. Main and
902
902
  other tasks' branches are never written.
903
+ - **Tasks woken on CI failures and reviews:** a check failing on a task's
904
+ PR head messages the task's own agent once per head, with the check, the
905
+ failing step and about 60 lines of `gh run view --log-failed` around the
906
+ error, telling it to fix the root cause, push and update its Reviewed:
907
+ line. A review or comment from a collaborator (not the account gh runs
908
+ as, which is the task's own) wakes it once per review or comment; one
909
+ GraphQL query per repository a minute fingerprints the open task PRs, and
910
+ only a PR whose fingerprint moved has its reviews read. Comment-only wakes
911
+ stop at 10 a task; changes requested always wake. A task mid-turn gets it
912
+ queued, the orchestrator hears one line per wake, and merged, closed,
913
+ dropped or done tasks aren't watched. On by default; **Settings →
914
+ Workspaces** turns it off per workspace.
903
915
  - **External PRs and sessions:** `review` and `sign_off` also take an open
904
916
  PR no AgentOS task owns (dispatch, a person, dependabot) by `#N`,
905
917
  `owner/repo#N` or URL, as long as its repository is one of the workspace's
@@ -5,7 +5,13 @@ type RouteParams = { params: Promise<{ id: string }> };
5
5
 
6
6
  export async function PATCH(request: NextRequest, { params }: RouteParams) {
7
7
  const { id } = await params;
8
- const { name, collapsed, sortOrder, maxRunningTasks } = await request.json();
8
+ const { name, collapsed, sortOrder, maxRunningTasks, wakeTasksOnPR } =
9
+ await request.json();
10
+ if (wakeTasksOnPR !== undefined && typeof wakeTasksOnPR !== "boolean")
11
+ return NextResponse.json(
12
+ { error: "wakeTasksOnPR is true or false" },
13
+ { status: 400 }
14
+ );
9
15
  let workspace;
10
16
  try {
11
17
  workspace = updateWorkspace(id, {
@@ -13,6 +19,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
13
19
  collapsed,
14
20
  sortOrder,
15
21
  maxRunningTasks,
22
+ wakeTasksOnPR,
16
23
  });
17
24
  } catch (error) {
18
25
  const message = error instanceof Error ? error.message : String(error);
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useWorkspacesQuery } from "@/data/workspaces";
3
+ import { SettingToggle } from "@/components/Settings/SettingToggle";
4
+ import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
4
5
  import { TaskLimitForm } from "./TaskLimitForm";
5
6
  import { RulesForm } from "./RulesForm";
6
7
  import { FreshStartForm } from "./FreshStartForm";
@@ -8,6 +9,7 @@ import { FreshStartForm } from "./FreshStartForm";
8
9
  // Settings > Workspaces: each workspace's own settings.
9
10
  export function WorkspaceSettings() {
10
11
  const { data: workspaces, isPending, isError, error } = useWorkspacesQuery();
12
+ const update = useUpdateWorkspace();
11
13
 
12
14
  if (isPending)
13
15
  return <div className="bg-muted/40 h-32 animate-pulse rounded-lg" />;
@@ -39,6 +41,12 @@ export function WorkspaceSettings() {
39
41
  workspace={w}
40
42
  />
41
43
  <RulesForm workspaceId={w.id} />
44
+ <SettingToggle
45
+ title="Wake tasks on CI failures and reviews"
46
+ detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
47
+ checked={w.wake_tasks_on_pr}
48
+ onChange={(on) => update.mutate({ id: w.id, wakeTasksOnPR: on })}
49
+ />
42
50
  </section>
43
51
  ))}
44
52
  <FreshStartForm />
@@ -53,6 +53,8 @@ export function useUpdateWorkspace() {
53
53
  collapsed?: boolean;
54
54
  // Running tasks at once; null for no limit.
55
55
  maxRunningTasks?: number | null;
56
+ // Message tasks about failing checks and reviews on their PRs.
57
+ wakeTasksOnPR?: boolean;
56
58
  }) =>
57
59
  json(
58
60
  await fetch(`/api/workspaces/${id}`, {
@@ -61,7 +63,7 @@ export function useUpdateWorkspace() {
61
63
  body: JSON.stringify(updates),
62
64
  })
63
65
  ),
64
- onMutate: async ({ id, maxRunningTasks, ...updates }) => {
66
+ onMutate: async ({ id, maxRunningTasks, wakeTasksOnPR, ...updates }) => {
65
67
  await queryClient.cancelQueries({ queryKey: workspaceKeys.list() });
66
68
  queryClient.setQueryData<Workspace[]>(workspaceKeys.list(), (prev) =>
67
69
  prev?.map((w) =>
@@ -72,6 +74,9 @@ export function useUpdateWorkspace() {
72
74
  ...(maxRunningTasks !== undefined && {
73
75
  max_running_tasks: maxRunningTasks,
74
76
  }),
77
+ ...(wakeTasksOnPR !== undefined && {
78
+ wake_tasks_on_pr: wakeTasksOnPR,
79
+ }),
75
80
  }
76
81
  : w
77
82
  )
@@ -1226,6 +1226,38 @@ const migrations: Migration[] = [
1226
1226
  installChangeTriggers(db, TABLES_WATCHED_56);
1227
1227
  },
1228
1228
  },
1229
+ {
1230
+ id: 57,
1231
+ name: "task_pr_wakes",
1232
+ up: (db) => {
1233
+ // What a task's agent was told about its PR (orchestrator/pr-wake.ts),
1234
+ // one row per failing head or review/comment id, claimed before the
1235
+ // message goes. kind: check, review or comment (comments are capped).
1236
+ db.exec(`
1237
+ CREATE TABLE IF NOT EXISTS task_pr_wakes (
1238
+ session_id TEXT NOT NULL,
1239
+ key TEXT NOT NULL,
1240
+ kind TEXT NOT NULL,
1241
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1242
+ PRIMARY KEY (session_id, key)
1243
+ );
1244
+ `);
1245
+ // pr_wake_since: reviews and comments older than this are taken as
1246
+ // known when a task's PR is first watched.
1247
+ const sessions = db.prepare(`PRAGMA table_info(sessions)`).all() as {
1248
+ name: string;
1249
+ }[];
1250
+ if (!sessions.some((c) => c.name === "pr_wake_since"))
1251
+ db.exec(`ALTER TABLE sessions ADD COLUMN pr_wake_since TEXT`);
1252
+ const workspaces = db.prepare(`PRAGMA table_info(workspaces)`).all() as {
1253
+ name: string;
1254
+ }[];
1255
+ if (!workspaces.some((c) => c.name === "wake_tasks_on_pr"))
1256
+ db.exec(
1257
+ `ALTER TABLE workspaces ADD COLUMN wake_tasks_on_pr INTEGER NOT NULL DEFAULT 1`
1258
+ );
1259
+ },
1260
+ },
1229
1261
  ];
1230
1262
 
1231
1263
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/types.ts CHANGED
@@ -97,6 +97,8 @@ export interface Session {
97
97
  rebased_to?: string | null;
98
98
  // The PR head the task was last asked to rebase by hand.
99
99
  rebase_asked_head?: string | null;
100
+ // When its PR's reviews and comments started being watched (pr-wake.ts).
101
+ pr_wake_since?: string | null;
100
102
  // Orchestration fields
101
103
  conductor_session_id: string | null;
102
104
  worker_task: string | null;
@@ -204,6 +206,9 @@ export interface Workspace {
204
206
  orch_paused_at: string | null;
205
207
  // Tasks running at once before new ones queue (lib/tasks/queue.ts); null: no limit.
206
208
  max_running_tasks: number | null;
209
+ // Tasks are messaged about failing checks and reviews on their PRs
210
+ // (orchestrator/pr-wake.ts).
211
+ wake_tasks_on_pr: boolean;
207
212
  created_at: string;
208
213
  }
209
214
 
@@ -52,6 +52,8 @@ Arm a task once its work is what you want merged, rather than calling \`sign_off
52
52
 
53
53
  When the base moves and a task's PR conflicts with it, AgentOS rebases it onto the base by itself if that's clean and its agent is idle with nothing uncommitted, pushes only the task's own branch, and tells you with one event. The task's Code review section still covers the rebased head, and your review of the old head carries over when the PR's own patch is unchanged (a fresh one starts otherwise). If it conflicts, the task is asked once to rebase and you get one event; don't send it the same request.
54
54
 
55
+ Tasks are woken by AgentOS itself when a check fails on their PR's head (with the failing step and the log around the error) and when a collaborator reviews or comments on their PR, and you get one "task X woken: ..." line for each. Don't message a task about the same failure or review; act only if it doesn't fix it.
56
+
55
57
  "Not yet" (CI running or settling, no review of this commit yet) is not a failure: wait for the event and try again. A failure counts against the task: a review that blocks counts when its verdict arrives, once per commit, and the second failure of the same gate goes to Saad, with a note in your chat, and from then on only he merges or drops that task. Don't retry it; say so in your chat and move on. ${mergeApprovalsOn() ? APPROVALS_ON : APPROVALS_OFF} \`land\` merges a whole stack only if every open item passes the same gates, and judges each one again at its own head right before merging it.
56
58
 
57
59
  ## Brakes
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { db, type StackItemStatus, type StackStatus } from "../db";
11
- import { isFinished } from "../tasks/state";
11
+ import { isFinished, type TaskPR } from "../tasks/state";
12
12
  import { ciWord } from "./describe";
13
13
  import type { SessionFacts } from "./facts";
14
14
  import { untrusted } from "./untrusted";
@@ -46,6 +46,13 @@ function hash(text: string): string {
46
46
  return (h >>> 0).toString(36);
47
47
  }
48
48
 
49
+ // A session's condition is keyed by what it is and the session.
50
+ export const conditionKey = (key: string, sessionId: string) =>
51
+ `${key}:${sessionId}`;
52
+ // A PR's CI verdict on a head.
53
+ export const ciKey = (pr: Pick<TaskPR, "number" | "head" | "checks">) =>
54
+ `ci:${pr.number}:${pr.head ?? ""}:${pr.checks}`;
55
+
49
56
  function sessionConditions(
50
57
  f: SessionFacts,
51
58
  now: number,
@@ -60,7 +67,7 @@ function sessionConditions(
60
67
  waiting = false
61
68
  ) =>
62
69
  out.push({
63
- key: `${key}:${f.id}`,
70
+ key: conditionKey(key, f.id),
64
71
  subject: f.id,
65
72
  line,
66
73
  sticky,
@@ -79,7 +86,7 @@ function sessionConditions(
79
86
  const settling = pr.checks === "pass" && (f.task?.ciSettleIn ?? 0) > 0;
80
87
  if ((pr.checks === "pass" && !settling) || pr.checks === "fail")
81
88
  add(
82
- `ci:${pr.number}:${pr.head ?? ""}:${pr.checks}`,
89
+ ciKey(pr),
83
90
  `${who}: ${ciWord(pr)}${pr.checks === "pass" ? " and settled" : ""}`
84
91
  );
85
92
  }
@@ -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,7 @@ 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";
20
21
 
21
22
  // Asks that stopped needing Saad: their task or PR finished some other
22
23
  // way, or brakes lifted with nothing trying to start.
@@ -61,8 +62,8 @@ export async function diffWorkspace(
61
62
  return nextDiffAt(now, facts, working);
62
63
  }
63
64
 
64
- // Rebases what conflicts with its base, then merges what's armed and
65
- // passes. In the background, one run per workspace at a time, so a git
65
+ // Wakes tasks whose PRs failed CI or got reviews, rebases what conflicts
66
+ // with its base, then merges what's armed and passes. In the background, one run per workspace at a time, so a git
66
67
  // fetch or a merge never holds up event delivery.
67
68
  const piping = new Set<string>();
68
69
 
@@ -70,6 +71,9 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
70
71
  if (piping.has(workspaceId)) return;
71
72
  piping.add(workspaceId);
72
73
  void (async () => {
74
+ await wakeTasks(workspaceId, facts).catch((error) =>
75
+ console.error(`Waking tasks in ${workspaceId}:`, error)
76
+ );
73
77
  await rebaseConflicting(workspaceId, facts);
74
78
  await mergeArmed(workspaceId, facts);
75
79
  })()
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),
@@ -0,0 +1,295 @@
1
+ // Reviews and comments on task PRs, read in two steps: a fingerprint of
2
+ // every watched PR in a repository from one GraphQL query (counts and the
3
+ // latest ids), and one PR's reviews and comments only when its fingerprint
4
+ // moved. Plus the part of a failed Actions job's log around its error.
5
+
6
+ import { redact } from "../orchestrator/untrusted";
7
+ import { run } from "./gh";
8
+
9
+ export interface Repo {
10
+ owner: string;
11
+ name: string;
12
+ }
13
+
14
+ export const repoOfPR = (url: string): Repo | null => {
15
+ const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/.exec(url);
16
+ return m ? { owner: m[1], name: m[2] } : null;
17
+ };
18
+
19
+ export interface Fingerprint {
20
+ // Changes whenever a review or a conversation comment is added.
21
+ print: string;
22
+ createdAt: string;
23
+ }
24
+
25
+ const graphql = async (
26
+ dir: string,
27
+ query: string,
28
+ vars: Record<string, string | number>
29
+ ) => {
30
+ const args = ["api", "graphql", "-f", `query=${query}`];
31
+ for (const [k, v] of Object.entries(vars))
32
+ args.push(typeof v === "number" ? "-F" : "-f", `${k}=${v}`);
33
+ return JSON.parse(await run("gh", args, dir, 30000)) as {
34
+ data?: { viewer?: { login: string }; repository?: Record<string, unknown> };
35
+ };
36
+ };
37
+
38
+ type Last = { totalCount: number; nodes: { id: string }[] };
39
+
40
+ // One query for all the given PRs of a repository.
41
+ export async function fingerprints(
42
+ dir: string,
43
+ repo: Repo,
44
+ numbers: number[]
45
+ ): Promise<Map<number, Fingerprint>> {
46
+ const prs = numbers.filter((n) => Number.isInteger(n) && n > 0);
47
+ const out = new Map<number, Fingerprint>();
48
+ if (!prs.length) return out;
49
+ const fields = prs
50
+ .map(
51
+ (n) =>
52
+ `p${n}: pullRequest(number: ${n}) { createdAt reviews(last: 1) { totalCount nodes { id } } comments(last: 1) { totalCount nodes { id } } }`
53
+ )
54
+ .join(" ");
55
+ const res = await graphql(
56
+ dir,
57
+ `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields} } }`,
58
+ { owner: repo.owner, name: repo.name }
59
+ );
60
+ for (const n of prs) {
61
+ const pr = res.data?.repository?.[`p${n}`] as
62
+ | { createdAt: string; reviews: Last; comments: Last }
63
+ | null
64
+ | undefined;
65
+ if (!pr) continue;
66
+ const part = (l: Last) => `${l.totalCount}:${l.nodes[0]?.id ?? ""}`;
67
+ out.set(n, {
68
+ print: `${part(pr.reviews)}/${part(pr.comments)}`,
69
+ createdAt: pr.createdAt,
70
+ });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ export interface PRNote {
76
+ path: string;
77
+ line: number | null;
78
+ body: string;
79
+ }
80
+
81
+ export interface PREvent {
82
+ id: string;
83
+ kind: "review" | "comment";
84
+ // A review's: APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING.
85
+ state: string | null;
86
+ author: string;
87
+ // GitHub's word on the author: OWNER, MEMBER, COLLABORATOR, NONE...
88
+ association: string;
89
+ at: string;
90
+ body: string;
91
+ // A review's inline comments.
92
+ notes: PRNote[];
93
+ }
94
+
95
+ export interface PRActivity {
96
+ // Who gh is signed in as: the task's own comments are posted as this.
97
+ viewer: string;
98
+ events: PREvent[];
99
+ }
100
+
101
+ type Author = { login: string } | null;
102
+
103
+ // The latest reviews and conversation comments on one PR.
104
+ export async function prActivity(
105
+ dir: string,
106
+ repo: Repo,
107
+ number: number
108
+ ): Promise<PRActivity> {
109
+ const res = await graphql(
110
+ dir,
111
+ `
112
+ query ($owner: String!, $name: String!, $number: Int!) {
113
+ viewer {
114
+ login
115
+ }
116
+ repository(owner: $owner, name: $name) {
117
+ pullRequest(number: $number) {
118
+ reviews(last: 30) {
119
+ nodes {
120
+ id
121
+ state
122
+ body
123
+ submittedAt
124
+ authorAssociation
125
+ author {
126
+ login
127
+ }
128
+ comments(first: 20) {
129
+ nodes {
130
+ path
131
+ line
132
+ originalLine
133
+ body
134
+ }
135
+ }
136
+ }
137
+ }
138
+ comments(last: 30) {
139
+ nodes {
140
+ id
141
+ body
142
+ createdAt
143
+ authorAssociation
144
+ author {
145
+ login
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ `,
153
+ { owner: repo.owner, name: repo.name, number }
154
+ );
155
+ const pr = res.data?.repository?.pullRequest as
156
+ | {
157
+ reviews: {
158
+ nodes: {
159
+ id: string;
160
+ state: string;
161
+ body: string;
162
+ submittedAt: string | null;
163
+ authorAssociation: string;
164
+ author: Author;
165
+ comments: {
166
+ nodes: {
167
+ path: string;
168
+ line: number | null;
169
+ originalLine: number | null;
170
+ body: string;
171
+ }[];
172
+ };
173
+ }[];
174
+ };
175
+ comments: {
176
+ nodes: {
177
+ id: string;
178
+ body: string;
179
+ createdAt: string;
180
+ authorAssociation: string;
181
+ author: Author;
182
+ }[];
183
+ };
184
+ }
185
+ | null
186
+ | undefined;
187
+ if (!pr || !res.data?.viewer) throw new Error(`PR #${number} not found`);
188
+ return {
189
+ viewer: res.data.viewer.login,
190
+ events: [
191
+ ...pr.reviews.nodes
192
+ .filter((r) => r.submittedAt)
193
+ .map((r) => ({
194
+ id: r.id,
195
+ kind: "review" as const,
196
+ state: r.state,
197
+ author: r.author?.login ?? "ghost",
198
+ association: r.authorAssociation,
199
+ at: r.submittedAt!,
200
+ body: r.body ?? "",
201
+ notes: r.comments.nodes.map((c) => ({
202
+ path: c.path,
203
+ line: c.line ?? c.originalLine,
204
+ body: c.body ?? "",
205
+ })),
206
+ })),
207
+ ...pr.comments.nodes.map((c) => ({
208
+ id: c.id,
209
+ kind: "comment" as const,
210
+ state: null,
211
+ author: c.author?.login ?? "ghost",
212
+ association: c.authorAssociation,
213
+ at: c.createdAt,
214
+ body: c.body ?? "",
215
+ notes: [],
216
+ })),
217
+ ].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)),
218
+ };
219
+ }
220
+
221
+ export const LOG_LINES = 60;
222
+ const LINE_MAX = 240;
223
+ // The line a failure is reported on; the runner's own last line isn't it.
224
+ const ERROR =
225
+ /##\[error\]|\berror\b|\bfail(ed|ure|s)?\b|\bFAIL\b|✗|×|AssertionError|panic:/i;
226
+ const RUNNER_EXIT = /Process completed with exit code/;
227
+
228
+ export interface FailedLog {
229
+ step: string | null;
230
+ tail: string;
231
+ }
232
+
233
+ // `gh run view --log-failed` lines are "job<TAB>step<TAB>time text". Keeps
234
+ // LOG_LINES lines from a little before the first error, without timestamps,
235
+ // colour codes or anything token-shaped.
236
+ export function trimLog(raw: string, max = LOG_LINES): FailedLog {
237
+ const rows = raw
238
+ .split("\n")
239
+ .filter((l) => l.trim())
240
+ .map((l) => {
241
+ const parts = l.split("\t");
242
+ const step = parts.length >= 3 ? parts[1] : null;
243
+ const text = (parts.length >= 3 ? parts.slice(2).join("\t") : l)
244
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")
245
+ .replace(/^?\d{4}-\d\d-\d\dT[\d:.]+Z ?/, "");
246
+ return {
247
+ step,
248
+ text: text.length > LINE_MAX ? `${text.slice(0, LINE_MAX)}…` : text,
249
+ };
250
+ });
251
+ if (!rows.length) return { step: null, tail: "" };
252
+ const hit = rows.findIndex(
253
+ (r) => ERROR.test(r.text) && !RUNNER_EXIT.test(r.text)
254
+ );
255
+ const anchor = hit >= 0 ? hit : rows.length - 1;
256
+ const start =
257
+ hit >= 0
258
+ ? Math.max(0, Math.min(hit - Math.floor(max / 3), rows.length - max))
259
+ : Math.max(0, rows.length - max);
260
+ const kept = rows.slice(start, start + max);
261
+ return {
262
+ step: rows[anchor].step,
263
+ tail: redact(kept.map((r) => r.text).join("\n")),
264
+ };
265
+ }
266
+
267
+ // The Actions job a check's details URL points at.
268
+ export const jobOf = (url: string | null | undefined) =>
269
+ url ? (/\/actions\/runs\/\d+\/job\/(\d+)/.exec(url)?.[1] ?? null) : null;
270
+
271
+ // The failed steps' log of an Actions job, trimmed; null for a check that
272
+ // isn't an Actions job.
273
+ export async function failedLog(
274
+ dir: string,
275
+ repo: Repo,
276
+ url: string | null | undefined
277
+ ): Promise<FailedLog | null> {
278
+ const job = jobOf(url);
279
+ if (!job) return null;
280
+ const raw = await run(
281
+ "gh",
282
+ [
283
+ "run",
284
+ "view",
285
+ "--repo",
286
+ `${repo.owner}/${repo.name}`,
287
+ "--job",
288
+ job,
289
+ "--log-failed",
290
+ ],
291
+ dir,
292
+ 60000
293
+ );
294
+ return trimLog(raw);
295
+ }
@@ -18,6 +18,8 @@ export interface TaskPR {
18
18
  // The head commit the checks ran on, and the first failing check's name.
19
19
  head?: string;
20
20
  failing?: string | null;
21
+ // Where that check's run is (an Actions job URL for a workflow check).
22
+ failingUrl?: string | null;
21
23
  // How many checks are registered on the head.
22
24
  checkCount?: number;
23
25
  // The body's Code review section: null when it has none, undefined
@@ -340,11 +342,17 @@ export function blockedReason(paneTail: string): string | null {
340
342
  }
341
343
 
342
344
  // The first failing check in gh's statusCheckRollup, by name.
345
+ const firstFailing = (rollup: RollupEntry[]) =>
346
+ liveChecks(rollup).find((r) => FAILED.includes(outcome(r)));
347
+
343
348
  export function failingCheck(rollup: RollupEntry[]): string | null {
344
- const c = liveChecks(rollup).find((r) => FAILED.includes(outcome(r)));
349
+ const c = firstFailing(rollup);
345
350
  return c ? (c.name ?? c.context ?? null) : null;
346
351
  }
347
352
 
353
+ export const failingUrl = (rollup: RollupEntry[]): string | null =>
354
+ firstFailing(rollup)?.detailsUrl ?? null;
355
+
348
356
  // Keys that answer Claude's folder-trust prompt with "trust", or null when the
349
357
  // pane isn't showing it. The selection marker starts on "No, exit".
350
358
  export function trustPromptKeys(pane: string): string[] | null {
package/lib/workspaces.ts CHANGED
@@ -6,11 +6,15 @@
6
6
  import { randomUUID } from "crypto";
7
7
  import { db, type Workspace } from "./db";
8
8
 
9
- type WorkspaceRow = Omit<Workspace, "collapsed"> & { collapsed: number };
9
+ type WorkspaceRow = Omit<Workspace, "collapsed" | "wake_tasks_on_pr"> & {
10
+ collapsed: number;
11
+ wake_tasks_on_pr: number;
12
+ };
10
13
 
11
14
  const toWorkspace = (row: WorkspaceRow): Workspace => ({
12
15
  ...row,
13
16
  collapsed: !!row.collapsed,
17
+ wake_tasks_on_pr: !!row.wake_tasks_on_pr,
14
18
  });
15
19
 
16
20
  export function listWorkspaces(): Workspace[] {
@@ -57,18 +61,20 @@ export function updateWorkspace(
57
61
  collapsed?: boolean;
58
62
  sortOrder?: number;
59
63
  maxRunningTasks?: number | null;
64
+ wakeTasksOnPR?: boolean;
60
65
  }
61
66
  ): Workspace | null {
62
67
  const existing = getWorkspace(id);
63
68
  if (!existing) return null;
64
69
  const name = updates.name?.trim() || existing.name;
65
70
  db.prepare(
66
- `UPDATE workspaces SET name = ?, collapsed = ?, sort_order = ?, max_running_tasks = ? WHERE id = ?`
71
+ `UPDATE workspaces SET name = ?, collapsed = ?, sort_order = ?, max_running_tasks = ?, wake_tasks_on_pr = ? WHERE id = ?`
67
72
  ).run(
68
73
  name,
69
74
  (updates.collapsed ?? existing.collapsed) ? 1 : 0,
70
75
  updates.sortOrder ?? existing.sort_order,
71
76
  taskLimit(updates.maxRunningTasks, existing.max_running_tasks),
77
+ (updates.wakeTasksOnPR ?? existing.wake_tasks_on_pr) ? 1 : 0,
72
78
  id
73
79
  );
74
80
  return getWorkspace(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.459",
3
+ "version": "0.3.460",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"