@saadnvd1/agent-os 0.3.457 → 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 +42 -5
- package/app/api/fresh-start/route.ts +19 -0
- package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +60 -0
- package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +29 -0
- package/app/api/workspaces/[id]/orchestrator/rules/route.ts +36 -0
- package/app/api/workspaces/[id]/route.ts +8 -1
- package/app/globals.css +10 -0
- package/components/Chat/ChatPanel.tsx +87 -4
- package/components/Chat/FindBar.tsx +113 -0
- package/components/Chat/Tools.tsx +1 -1
- package/components/Chat/find-highlight.test.ts +56 -0
- package/components/Chat/find-highlight.ts +78 -0
- package/components/Chat/rowState.tsx +44 -4
- package/components/Chat/stick-to-bottom.ts +7 -1
- package/components/Chat/useFind.ts +253 -0
- package/components/Pane/MobileTabBar.tsx +18 -0
- package/components/Pane/index.tsx +6 -0
- package/components/Workspaces/FreshStartForm.tsx +128 -0
- package/components/Workspaces/RulesForm.tsx +166 -0
- package/components/Workspaces/WorkspaceSettings.tsx +13 -1
- package/components/Workspaces/fresh-start.ts +19 -0
- package/data/chat/useChat.ts +29 -0
- package/data/fresh-start/index.ts +34 -0
- package/data/orchestrators/rules.ts +80 -0
- package/data/push/topics.ts +4 -0
- package/data/workspaces/index.ts +6 -1
- package/lib/chat/demo.ts +1 -0
- package/lib/chat/events.ts +7 -1
- package/lib/chat/find.ts +278 -0
- package/lib/chat/refresh-limits.ts +5 -0
- package/lib/chat/refresh.ts +82 -0
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/search.ts +117 -0
- package/lib/chat/socket.ts +12 -0
- package/lib/chat/worker/extras.ts +11 -1
- package/lib/chat/worker/host.ts +70 -5
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +69 -0
- package/lib/db/types.ts +9 -0
- package/lib/orchestrator/brief.ts +16 -3
- package/lib/orchestrator/conditions.ts +10 -3
- package/lib/orchestrator/digest.ts +339 -0
- package/lib/orchestrator/pr-wake.ts +385 -0
- package/lib/orchestrator/presence-binding.ts +5 -0
- package/lib/orchestrator/rules.ts +124 -0
- package/lib/orchestrator/serve.ts +11 -0
- package/lib/orchestrator/tool-names.ts +2 -0
- package/lib/orchestrator/tool-schemas.ts +10 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/orchestrator/watcher.ts +6 -2
- package/lib/orchestrator/worker.ts +5 -0
- package/lib/security/presence.ts +2 -1
- 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
- package/styles/themes.css +5 -0
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,8 @@ export const askPresence = (askId: number, binding: string) =>
|
|
|
10
10
|
export const resumePresence = (workspaceId: string) => `resume:${workspaceId}`;
|
|
11
11
|
export const revokePresence = (passkeyId: string) => `revoke:${passkeyId}`;
|
|
12
12
|
export const APPROVALS_OFF_PRESENCE = "approvals-off";
|
|
13
|
+
// Writing a workspace's standing rules as Saad.
|
|
14
|
+
export const rulesPresence = (workspaceId: string) => `rules:${workspaceId}`;
|
|
13
15
|
|
|
14
16
|
export function presenceBinding(b: {
|
|
15
17
|
purpose?: PresencePurpose;
|
|
@@ -36,6 +38,9 @@ export function presenceBinding(b: {
|
|
|
36
38
|
return "enroll";
|
|
37
39
|
case "approvals-off":
|
|
38
40
|
return APPROVALS_OFF_PRESENCE;
|
|
41
|
+
case "rules":
|
|
42
|
+
if (!b.workspaceId) throw new PresenceError("Which workspace?");
|
|
43
|
+
return rulesPresence(b.workspaceId);
|
|
39
44
|
case "revoke":
|
|
40
45
|
if (!b.passkeyId) throw new PresenceError("Which passkey?");
|
|
41
46
|
return revokePresence(b.passkeyId);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A workspace's standing rules: what its orchestrator must keep knowing
|
|
3
|
+
* across fresh starts ("never merge #111", "briefs must not name
|
|
4
|
+
* competitors"). The orchestrator writes them with rule_add and rule_remove,
|
|
5
|
+
* Saad in Settings → Workspaces, and every start reads them into its brief.
|
|
6
|
+
* They restrict and inform; none can approve anything. A removed rule stays
|
|
7
|
+
* in the table, marked.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { db } from "../db";
|
|
11
|
+
import { redact } from "./untrusted";
|
|
12
|
+
|
|
13
|
+
export type RuleAuthor = "orchestrator" | "saad";
|
|
14
|
+
|
|
15
|
+
export interface RuleRow {
|
|
16
|
+
id: number;
|
|
17
|
+
workspace_id: string;
|
|
18
|
+
text: string;
|
|
19
|
+
added_by: RuleAuthor;
|
|
20
|
+
created_at: string;
|
|
21
|
+
updated_at: string;
|
|
22
|
+
removed_at: string | null;
|
|
23
|
+
removed_by: RuleAuthor | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Kept small: every one is in every start's prompt.
|
|
27
|
+
export const MAX_RULES = 25;
|
|
28
|
+
export const MAX_RULE_CHARS = 300;
|
|
29
|
+
|
|
30
|
+
function clean(text: string): string {
|
|
31
|
+
const body = redact(text).replace(/\s+/g, " ").trim();
|
|
32
|
+
if (!body) throw new Error("The rule is empty");
|
|
33
|
+
if (body.length > MAX_RULE_CHARS)
|
|
34
|
+
throw new Error(`A rule is at most ${MAX_RULE_CHARS} characters`);
|
|
35
|
+
return body;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function listRules(workspaceId: string): RuleRow[] {
|
|
39
|
+
return db
|
|
40
|
+
.prepare(
|
|
41
|
+
`SELECT * FROM orchestrator_rules
|
|
42
|
+
WHERE workspace_id = ? AND removed_at IS NULL ORDER BY id`
|
|
43
|
+
)
|
|
44
|
+
.all(workspaceId) as RuleRow[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getRule(workspaceId: string, id: number): RuleRow | null {
|
|
48
|
+
return (
|
|
49
|
+
(db
|
|
50
|
+
.prepare(
|
|
51
|
+
`SELECT * FROM orchestrator_rules
|
|
52
|
+
WHERE id = ? AND workspace_id = ? AND removed_at IS NULL`
|
|
53
|
+
)
|
|
54
|
+
.get(id, workspaceId) as RuleRow | undefined) ?? null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The same text twice is the rule already there.
|
|
59
|
+
export function addRule(
|
|
60
|
+
workspaceId: string,
|
|
61
|
+
text: string,
|
|
62
|
+
by: RuleAuthor
|
|
63
|
+
): RuleRow {
|
|
64
|
+
const body = clean(text);
|
|
65
|
+
return db.transaction(() => {
|
|
66
|
+
const rules = listRules(workspaceId);
|
|
67
|
+
const same = rules.find((r) => r.text === body);
|
|
68
|
+
// Saad writing one the orchestrator kept makes it his.
|
|
69
|
+
if (same && by === "saad" && same.added_by !== "saad") {
|
|
70
|
+
db.prepare(
|
|
71
|
+
`UPDATE orchestrator_rules SET added_by = 'saad', updated_at = datetime('now')
|
|
72
|
+
WHERE id = ?`
|
|
73
|
+
).run(same.id);
|
|
74
|
+
return getRule(workspaceId, same.id)!;
|
|
75
|
+
}
|
|
76
|
+
if (same) return same;
|
|
77
|
+
if (rules.length >= MAX_RULES)
|
|
78
|
+
throw new Error(
|
|
79
|
+
`This workspace has ${MAX_RULES} rules already; remove one first`
|
|
80
|
+
);
|
|
81
|
+
const { lastInsertRowid } = db
|
|
82
|
+
.prepare(
|
|
83
|
+
`INSERT INTO orchestrator_rules (workspace_id, text, added_by) VALUES (?, ?, ?)`
|
|
84
|
+
)
|
|
85
|
+
.run(workspaceId, body, by);
|
|
86
|
+
return getRule(workspaceId, Number(lastInsertRowid))!;
|
|
87
|
+
})();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function updateRule(
|
|
91
|
+
workspaceId: string,
|
|
92
|
+
id: number,
|
|
93
|
+
text: string
|
|
94
|
+
): RuleRow {
|
|
95
|
+
const body = clean(text);
|
|
96
|
+
const { changes } = db
|
|
97
|
+
.prepare(
|
|
98
|
+
`UPDATE orchestrator_rules SET text = ?, updated_at = datetime('now')
|
|
99
|
+
WHERE id = ? AND workspace_id = ? AND removed_at IS NULL`
|
|
100
|
+
)
|
|
101
|
+
.run(body, id, workspaceId);
|
|
102
|
+
if (!changes) throw new Error(`No rule ${id} in this workspace`);
|
|
103
|
+
return getRule(workspaceId, id)!;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function removeRule(
|
|
107
|
+
workspaceId: string,
|
|
108
|
+
id: number,
|
|
109
|
+
by: RuleAuthor
|
|
110
|
+
): RuleRow {
|
|
111
|
+
const rule = getRule(workspaceId, id);
|
|
112
|
+
if (!rule) throw new Error(`No rule ${id} in this workspace`);
|
|
113
|
+
// Saad's restrictions hold until he lifts them: text the orchestrator
|
|
114
|
+
// read can't talk it into removing one.
|
|
115
|
+
if (by === "orchestrator" && rule.added_by === "saad")
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Rule ${id} is Saad's: only he removes it, in Settings → Workspaces`
|
|
118
|
+
);
|
|
119
|
+
db.prepare(
|
|
120
|
+
`UPDATE orchestrator_rules SET removed_at = datetime('now'), removed_by = ?
|
|
121
|
+
WHERE id = ?`
|
|
122
|
+
).run(by, id);
|
|
123
|
+
return rule;
|
|
124
|
+
}
|
|
@@ -15,6 +15,7 @@ import { sessionFacts } from "./facts";
|
|
|
15
15
|
import { describeExternal, externalSessions } from "./external-sessions";
|
|
16
16
|
import { resolveStaleAsks } from "./ask-settle";
|
|
17
17
|
import { addNote } from "./notes";
|
|
18
|
+
import { addRule, removeRule } from "./rules";
|
|
18
19
|
import { askSaad } from "./ask-tool";
|
|
19
20
|
import { listOrchestratorPeers, messageOrchestrator } from "./cross";
|
|
20
21
|
import { PAUSED_REFUSAL } from "./pause";
|
|
@@ -122,6 +123,16 @@ export async function runTool(
|
|
|
122
123
|
case "note":
|
|
123
124
|
addNote(w, p(tool, raw).text);
|
|
124
125
|
return "Noted.";
|
|
126
|
+
case "rule_add": {
|
|
127
|
+
const rule = addRule(w, p(tool, raw).text, "orchestrator");
|
|
128
|
+
addNote(w, `Standing rule ${rule.id}: ${rule.text}`);
|
|
129
|
+
return `Rule ${rule.id} kept: it's in your brief on every start.`;
|
|
130
|
+
}
|
|
131
|
+
case "rule_remove": {
|
|
132
|
+
const rule = removeRule(w, p(tool, raw).id, "orchestrator");
|
|
133
|
+
addNote(w, `Removed standing rule ${rule.id}: ${rule.text}`);
|
|
134
|
+
return `Rule ${rule.id} removed.`;
|
|
135
|
+
}
|
|
125
136
|
case "review": {
|
|
126
137
|
const a = p(tool, raw);
|
|
127
138
|
return reviewTarget(w, a.target, { fresh: a.fresh });
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { BRANCH_NAME } from "../git";
|
|
6
6
|
import { RAISED_KINDS } from "./asks";
|
|
7
|
+
import { MAX_RULE_CHARS } from "./rules";
|
|
7
8
|
|
|
8
9
|
const ref = (what: string) => z.string().trim().min(1).max(300).describe(what);
|
|
9
10
|
const session = ref("Session name, project/name, or id from sessions");
|
|
@@ -100,6 +101,15 @@ export const TOOL_SHAPES = {
|
|
|
100
101
|
stop: { session },
|
|
101
102
|
done: { session },
|
|
102
103
|
note: { text: z.string().trim().min(1).max(2000) },
|
|
104
|
+
rule_add: {
|
|
105
|
+
text: z
|
|
106
|
+
.string()
|
|
107
|
+
.trim()
|
|
108
|
+
.min(1)
|
|
109
|
+
.max(MAX_RULE_CHARS)
|
|
110
|
+
.describe("The rule, one sentence"),
|
|
111
|
+
},
|
|
112
|
+
rule_remove: { id: z.number().int().positive().describe("The rule's id") },
|
|
103
113
|
review: {
|
|
104
114
|
target: ref("The task, or its PR (#12 or URL)"),
|
|
105
115
|
fresh: z
|
|
@@ -72,6 +72,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
72
72
|
stop: "Stop a session's agent, keeping its worktree and branch.",
|
|
73
73
|
done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
|
|
74
74
|
note: "Add a line to this workspace's decision log; it shows in your chat.",
|
|
75
|
+
rule_add:
|
|
76
|
+
'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name competitors"). It restricts or informs; it can never approve anything.',
|
|
77
|
+
rule_remove:
|
|
78
|
+
"Remove a standing rule you kept, by its id, only when Saad says so or it no longer applies (the PR it names merged or closed). Saad's rules are his to remove.",
|
|
75
79
|
review:
|
|
76
80
|
"Start an independent read-only review of a PR at its exact head commit, or read the stored verdict for that commit. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL, even one no task owns.",
|
|
77
81
|
sign_off:
|
|
@@ -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
|
-
//
|
|
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
|
})()
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
orchestratorToolsDigest,
|
|
10
10
|
} from "./tools";
|
|
11
11
|
import type { RoleExtras } from "../chat/worker/extras";
|
|
12
|
+
import { refreshSettings } from "../chat/refresh";
|
|
12
13
|
|
|
13
14
|
// What an orchestrator's chat worker starts with on top of any chat: its
|
|
14
15
|
// brief, its tools, and permissions fixed by its role.
|
|
@@ -29,5 +30,9 @@ export async function orchestratorExtras(
|
|
|
29
30
|
},
|
|
30
31
|
...ORCHESTRATOR_PERMISSIONS,
|
|
31
32
|
toolsDigest: orchestratorToolsDigest(),
|
|
33
|
+
refresh: {
|
|
34
|
+
atTokens: refreshSettings().orchestratorTokens,
|
|
35
|
+
role: "orchestrator",
|
|
36
|
+
},
|
|
32
37
|
};
|
|
33
38
|
}
|
package/lib/security/presence.ts
CHANGED
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),
|