@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,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The away sweep's choices, made by code from what the server already knows
|
|
3
|
+
* (session facts, git status, stored review verdicts), never by a model
|
|
4
|
+
* reading the workspace. Pure: ./run gathers the inputs and acts.
|
|
5
|
+
*
|
|
6
|
+
* - A task with no activity for STALL_MS and nothing to wait on is nudged,
|
|
7
|
+
* at most MAX_NUDGES times a period, and again only after it moved.
|
|
8
|
+
* - A task idle with uncommitted work and no PR is asked to commit and open
|
|
9
|
+
* one, or say why not.
|
|
10
|
+
* - A PR whose CI settled green with no review of its head, or one that was
|
|
11
|
+
* lost (errored, or cut off and stale), has its review run again, once
|
|
12
|
+
* the sweep has seen it that way twice, so the orchestrator had its turn.
|
|
13
|
+
* - An interactive session is Saad's: one with uncommitted work is asked to
|
|
14
|
+
* wrap up, once a period, and nothing more.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isFinished } from "../tasks/state";
|
|
18
|
+
import type { SessionFacts } from "../orchestrator/facts";
|
|
19
|
+
|
|
20
|
+
export const SWEEP_EVERY_MS = 10 * 60 * 1000;
|
|
21
|
+
export const STALL_MS = 30 * 60 * 1000;
|
|
22
|
+
export const IDLE_DIRTY_MS = 15 * 60 * 1000;
|
|
23
|
+
export const MAX_NUDGES = 2;
|
|
24
|
+
// A review "running" this long was cut off (as clearStaleRunning has it).
|
|
25
|
+
export const STALE_REVIEW_MS = 30 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export type CandidateKind = "nudge" | "commit" | "review" | "wrapup";
|
|
28
|
+
|
|
29
|
+
export interface Candidate {
|
|
30
|
+
kind: CandidateKind;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
// Acted on once per Away period.
|
|
33
|
+
key: string;
|
|
34
|
+
// What the session is told (none for a review).
|
|
35
|
+
message?: string;
|
|
36
|
+
// One line for the decision log.
|
|
37
|
+
log: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ReviewSeen {
|
|
41
|
+
status: "running" | "pass" | "block" | "error";
|
|
42
|
+
at: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SweepView {
|
|
46
|
+
now: number;
|
|
47
|
+
facts: SessionFacts[];
|
|
48
|
+
// Sessions with uncommitted changes in their worktree.
|
|
49
|
+
dirty: ReadonlySet<string>;
|
|
50
|
+
// The stored review verdict of a session's commit, if any.
|
|
51
|
+
reviewOf: (sessionId: string, sha: string) => ReviewSeen | null;
|
|
52
|
+
// Tasks that are Saad's now: escalated, or an open ask on them.
|
|
53
|
+
held: ReadonlySet<string>;
|
|
54
|
+
// Keys acted on (or seen, for reviews) this period.
|
|
55
|
+
acted: ReadonlySet<string>;
|
|
56
|
+
// Nudges sent to each session this period.
|
|
57
|
+
nudges: ReadonlyMap<string, number>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const minutes = (ms: number) => Math.floor(ms / 60000);
|
|
61
|
+
|
|
62
|
+
const BLOCKED_HINT =
|
|
63
|
+
"If only Saad can unblock it, end with a line starting BLOCKED: that says what you need.";
|
|
64
|
+
|
|
65
|
+
// Live work with an agent to answer: not finished, not a linked machine's
|
|
66
|
+
// copy, not setting up, not waiting on someone already.
|
|
67
|
+
function workable(f: SessionFacts): boolean {
|
|
68
|
+
if (f.status === "dead" || f.status === "waiting" || f.needsInput)
|
|
69
|
+
return false;
|
|
70
|
+
if (f.activity === "setting up") return false;
|
|
71
|
+
if (
|
|
72
|
+
f.task &&
|
|
73
|
+
(isFinished(f.task.state) ||
|
|
74
|
+
f.task.state === "moving" ||
|
|
75
|
+
f.task.state === "exited" ||
|
|
76
|
+
f.task.blocked !== null)
|
|
77
|
+
)
|
|
78
|
+
return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Sessions worth a `git status`: quiet long enough that uncommitted work
|
|
83
|
+
// would matter, and not already asked about it this period. Keeps the
|
|
84
|
+
// sweep from reading every worktree.
|
|
85
|
+
export function needsGitCheck(
|
|
86
|
+
facts: SessionFacts[],
|
|
87
|
+
now: number,
|
|
88
|
+
acted: ReadonlySet<string>
|
|
89
|
+
): string[] {
|
|
90
|
+
return facts
|
|
91
|
+
.filter(
|
|
92
|
+
(f) =>
|
|
93
|
+
workable(f) &&
|
|
94
|
+
f.status === "idle" &&
|
|
95
|
+
!f.task?.pr &&
|
|
96
|
+
now - f.lastActive >= IDLE_DIRTY_MS &&
|
|
97
|
+
!acted.has(f.task ? commitKey(f) : wrapupKey(f))
|
|
98
|
+
)
|
|
99
|
+
.map((f) => f.id);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const commitKey = (f: SessionFacts) => `commit:${f.id}:${f.lastActive}`;
|
|
103
|
+
const wrapupKey = (f: SessionFacts) => `wrapup:${f.id}`;
|
|
104
|
+
export const seenKey = (id: string, sha: string) => `review-seen:${id}:${sha}`;
|
|
105
|
+
|
|
106
|
+
function taskCandidate(f: SessionFacts, v: SweepView): Candidate | null {
|
|
107
|
+
const task = f.task!;
|
|
108
|
+
const pr = task.pr;
|
|
109
|
+
const quiet = v.now - f.lastActive;
|
|
110
|
+
const who = `task ${f.name}`;
|
|
111
|
+
|
|
112
|
+
if (!pr && f.status === "idle" && v.dirty.has(f.id)) {
|
|
113
|
+
const key = commitKey(f);
|
|
114
|
+
if (v.acted.has(key)) return null;
|
|
115
|
+
return {
|
|
116
|
+
kind: "commit",
|
|
117
|
+
sessionId: f.id,
|
|
118
|
+
key,
|
|
119
|
+
message: `Saad is away. You've been idle ${minutes(quiet)} minutes with uncommitted work and no pull request. Commit it and open a pull request, as your task says. If it shouldn't be one, end with a line starting BLOCKED: that says why not.`,
|
|
120
|
+
log: `${who}: idle ${minutes(quiet)}m with uncommitted work and no PR; asked it to commit and open one, or say why not.`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const ciFailing = pr?.state === "OPEN" && pr.checks === "fail";
|
|
125
|
+
if ((!pr || ciFailing) && quiet >= STALL_MS) {
|
|
126
|
+
if ((v.nudges.get(f.id) ?? 0) >= MAX_NUDGES) return null;
|
|
127
|
+
const key = `nudge:${f.id}:${f.lastActive}`;
|
|
128
|
+
if (v.acted.has(key)) return null;
|
|
129
|
+
const what = ciFailing
|
|
130
|
+
? `CI is failing on PR #${pr!.number}. Fix it and push.`
|
|
131
|
+
: "Carry the task on to a pull request.";
|
|
132
|
+
return {
|
|
133
|
+
kind: "nudge",
|
|
134
|
+
sessionId: f.id,
|
|
135
|
+
key,
|
|
136
|
+
message: `Saad is away, and AgentOS hasn't seen activity from you in ${minutes(quiet)} minutes. ${what} ${BLOCKED_HINT}`,
|
|
137
|
+
log: `${who}: no activity for ${minutes(quiet)}m${ciFailing ? ` with CI failing on PR #${pr!.number}` : ", no PR"}; nudged it.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function reviewCandidate(f: SessionFacts, v: SweepView): Candidate | null {
|
|
144
|
+
const pr = f.task?.pr;
|
|
145
|
+
if (
|
|
146
|
+
!f.task ||
|
|
147
|
+
isFinished(f.task.state) ||
|
|
148
|
+
pr?.state !== "OPEN" ||
|
|
149
|
+
!pr.head ||
|
|
150
|
+
pr.checks !== "pass" ||
|
|
151
|
+
(f.task.ciSettleIn ?? 0) > 0 ||
|
|
152
|
+
v.held.has(f.id)
|
|
153
|
+
)
|
|
154
|
+
return null;
|
|
155
|
+
const seen = v.reviewOf(f.id, pr.head);
|
|
156
|
+
const lost =
|
|
157
|
+
!seen ||
|
|
158
|
+
seen.status === "error" ||
|
|
159
|
+
(seen.status === "running" && v.now - seen.at >= STALE_REVIEW_MS);
|
|
160
|
+
if (!lost) return null;
|
|
161
|
+
const key = `review:${f.id}:${pr.head}`;
|
|
162
|
+
if (v.acted.has(key)) return null;
|
|
163
|
+
const why = !seen
|
|
164
|
+
? "never started"
|
|
165
|
+
: seen.status === "error"
|
|
166
|
+
? "lost (it errored)"
|
|
167
|
+
: "lost (cut off)";
|
|
168
|
+
return {
|
|
169
|
+
kind: "review",
|
|
170
|
+
sessionId: f.id,
|
|
171
|
+
key,
|
|
172
|
+
log: `task ${f.name}: the review of PR #${pr.number} at ${pr.head.slice(0, 7)} was ${why}; ran it again.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function wrapupCandidate(f: SessionFacts, v: SweepView): Candidate | null {
|
|
177
|
+
if (f.status !== "idle" || !v.dirty.has(f.id)) return null;
|
|
178
|
+
const key = wrapupKey(f);
|
|
179
|
+
if (v.acted.has(key)) return null;
|
|
180
|
+
return {
|
|
181
|
+
kind: "wrapup",
|
|
182
|
+
sessionId: f.id,
|
|
183
|
+
key,
|
|
184
|
+
message:
|
|
185
|
+
"Saad is away. This session has uncommitted work. Wrap up: on a feature branch, commit what you have with a clear message; on the default branch, leave it uncommitted. Then stop and don't start anything new; Saad picks this up when he's back.",
|
|
186
|
+
log: `${f.name}: Saad's own session, idle with uncommitted work; asked it to wrap up.`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// What to act on now. A review candidate seen for the first time is
|
|
191
|
+
// returned with kind "review" only once its seen-key is in `acted`; the
|
|
192
|
+
// caller records the seen-key otherwise (see `firstSeen`).
|
|
193
|
+
export function pickCandidates(v: SweepView): {
|
|
194
|
+
act: Candidate[];
|
|
195
|
+
firstSeen: string[];
|
|
196
|
+
} {
|
|
197
|
+
const act: Candidate[] = [];
|
|
198
|
+
const firstSeen: string[] = [];
|
|
199
|
+
for (const f of v.facts) {
|
|
200
|
+
if (f.task) {
|
|
201
|
+
const review = reviewCandidate(f, v);
|
|
202
|
+
if (review) {
|
|
203
|
+
const seen = seenKey(f.id, f.task.pr!.head!);
|
|
204
|
+
if (v.acted.has(seen)) act.push(review);
|
|
205
|
+
else firstSeen.push(seen);
|
|
206
|
+
}
|
|
207
|
+
if (!workable(f) || v.held.has(f.id)) continue;
|
|
208
|
+
const c = taskCandidate(f, v);
|
|
209
|
+
if (c) act.push(c);
|
|
210
|
+
} else if (workable(f)) {
|
|
211
|
+
const c = wrapupCandidate(f, v);
|
|
212
|
+
if (c) act.push(c);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { act, firstSeen };
|
|
216
|
+
}
|
package/lib/away/view.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// What the browser gets: Away's state now, and the newest report Saad
|
|
2
|
+
// hasn't dismissed. Times are ISO.
|
|
3
|
+
|
|
4
|
+
import type { AwayReport } from "./report";
|
|
5
|
+
import {
|
|
6
|
+
activeAway,
|
|
7
|
+
fromSql,
|
|
8
|
+
isExpired,
|
|
9
|
+
unseenReport,
|
|
10
|
+
type AwayRow,
|
|
11
|
+
} from "./state";
|
|
12
|
+
import { turnAwayOff } from "./run";
|
|
13
|
+
|
|
14
|
+
export interface AwayView {
|
|
15
|
+
away: {
|
|
16
|
+
id: number;
|
|
17
|
+
setBy: string;
|
|
18
|
+
startedAt: string;
|
|
19
|
+
expiresAt: string | null;
|
|
20
|
+
} | null;
|
|
21
|
+
report: AwayReport | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const iso = (t: string) => new Date(fromSql(t)).toISOString();
|
|
25
|
+
|
|
26
|
+
function reportOf(row: AwayRow | null): AwayReport | null {
|
|
27
|
+
if (!row?.report) return null;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(row.report) as AwayReport;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function awayView(now = Date.now()): AwayView {
|
|
36
|
+
let away = activeAway();
|
|
37
|
+
// The watcher ends it on time; this covers a server running without one.
|
|
38
|
+
if (away && isExpired(away, now)) {
|
|
39
|
+
turnAwayOff("expired", now);
|
|
40
|
+
away = null;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
away: away && {
|
|
44
|
+
id: away.id,
|
|
45
|
+
setBy: away.set_by,
|
|
46
|
+
startedAt: iso(away.started_at),
|
|
47
|
+
expiresAt: away.expires_at ? iso(away.expires_at) : null,
|
|
48
|
+
},
|
|
49
|
+
report: reportOf(unseenReport()),
|
|
50
|
+
};
|
|
51
|
+
}
|
package/lib/chat/events.ts
CHANGED
package/lib/db/changes.ts
CHANGED
|
@@ -27,11 +27,14 @@ export const TABLES_WATCHED_50 = ["task_queue"] as const;
|
|
|
27
27
|
|
|
28
28
|
export const TABLES_WATCHED_56 = ["orchestrator_rules"] as const;
|
|
29
29
|
|
|
30
|
+
export const TABLES_WATCHED_58 = ["away_periods"] as const;
|
|
31
|
+
|
|
30
32
|
// Every table any migration watches, for the browser's topic map to cover.
|
|
31
33
|
export const WATCHED_TABLES: readonly string[] = [
|
|
32
34
|
...TABLES_WATCHED_45,
|
|
33
35
|
...TABLES_WATCHED_50,
|
|
34
36
|
...TABLES_WATCHED_56,
|
|
37
|
+
...TABLES_WATCHED_58,
|
|
35
38
|
];
|
|
36
39
|
|
|
37
40
|
export function installChangeTriggers(
|
package/lib/db/migrations.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
TABLES_WATCHED_45,
|
|
4
4
|
TABLES_WATCHED_50,
|
|
5
5
|
TABLES_WATCHED_56,
|
|
6
|
+
TABLES_WATCHED_58,
|
|
6
7
|
} from "./changes";
|
|
7
8
|
import type Database from "better-sqlite3";
|
|
8
9
|
|
|
@@ -1226,6 +1227,106 @@ const migrations: Migration[] = [
|
|
|
1226
1227
|
installChangeTriggers(db, TABLES_WATCHED_56);
|
|
1227
1228
|
},
|
|
1228
1229
|
},
|
|
1230
|
+
{
|
|
1231
|
+
id: 57,
|
|
1232
|
+
name: "task_pr_wakes",
|
|
1233
|
+
up: (db) => {
|
|
1234
|
+
// What a task's agent was told about its PR (orchestrator/pr-wake.ts),
|
|
1235
|
+
// one row per failing head or review/comment id, claimed before the
|
|
1236
|
+
// message goes. kind: check, review or comment (comments are capped).
|
|
1237
|
+
db.exec(`
|
|
1238
|
+
CREATE TABLE IF NOT EXISTS task_pr_wakes (
|
|
1239
|
+
session_id TEXT NOT NULL,
|
|
1240
|
+
key TEXT NOT NULL,
|
|
1241
|
+
kind TEXT NOT NULL,
|
|
1242
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1243
|
+
PRIMARY KEY (session_id, key)
|
|
1244
|
+
);
|
|
1245
|
+
`);
|
|
1246
|
+
// pr_wake_since: reviews and comments older than this are taken as
|
|
1247
|
+
// known when a task's PR is first watched.
|
|
1248
|
+
const sessions = db.prepare(`PRAGMA table_info(sessions)`).all() as {
|
|
1249
|
+
name: string;
|
|
1250
|
+
}[];
|
|
1251
|
+
if (!sessions.some((c) => c.name === "pr_wake_since"))
|
|
1252
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN pr_wake_since TEXT`);
|
|
1253
|
+
const workspaces = db.prepare(`PRAGMA table_info(workspaces)`).all() as {
|
|
1254
|
+
name: string;
|
|
1255
|
+
}[];
|
|
1256
|
+
if (!workspaces.some((c) => c.name === "wake_tasks_on_pr"))
|
|
1257
|
+
db.exec(
|
|
1258
|
+
`ALTER TABLE workspaces ADD COLUMN wake_tasks_on_pr INTEGER NOT NULL DEFAULT 1`
|
|
1259
|
+
);
|
|
1260
|
+
},
|
|
1261
|
+
},
|
|
1262
|
+
{
|
|
1263
|
+
id: 58,
|
|
1264
|
+
name: "away_mode",
|
|
1265
|
+
up: (db) => {
|
|
1266
|
+
// Saad's Away mode (lib/away): one row per time he stepped away. The
|
|
1267
|
+
// report is built from the database when it ends and kept here.
|
|
1268
|
+
db.exec(`
|
|
1269
|
+
CREATE TABLE IF NOT EXISTS away_periods (
|
|
1270
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1271
|
+
set_by TEXT NOT NULL,
|
|
1272
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1273
|
+
expires_at TEXT,
|
|
1274
|
+
ended_at TEXT,
|
|
1275
|
+
ended_by TEXT,
|
|
1276
|
+
last_sweep_at TEXT,
|
|
1277
|
+
asks_notified_at TEXT,
|
|
1278
|
+
report TEXT,
|
|
1279
|
+
report_seen_at TEXT
|
|
1280
|
+
)
|
|
1281
|
+
`);
|
|
1282
|
+
// What the away sweep did, once per key per period: a nudge, a
|
|
1283
|
+
// request to commit, a review run again, a deploy checked.
|
|
1284
|
+
db.exec(`
|
|
1285
|
+
CREATE TABLE IF NOT EXISTS away_actions (
|
|
1286
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1287
|
+
away_id INTEGER NOT NULL,
|
|
1288
|
+
workspace_id TEXT NOT NULL,
|
|
1289
|
+
subject TEXT NOT NULL,
|
|
1290
|
+
kind TEXT NOT NULL,
|
|
1291
|
+
key TEXT NOT NULL,
|
|
1292
|
+
detail TEXT,
|
|
1293
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1294
|
+
UNIQUE (away_id, key)
|
|
1295
|
+
)
|
|
1296
|
+
`);
|
|
1297
|
+
// When a task merged, whichever path merged it.
|
|
1298
|
+
const columns = db.prepare(`PRAGMA table_info(sessions)`).all() as {
|
|
1299
|
+
name: string;
|
|
1300
|
+
}[];
|
|
1301
|
+
if (!columns.some((c) => c.name === "merged_at"))
|
|
1302
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN merged_at TEXT`);
|
|
1303
|
+
// When a task ended without a merge (dropped, or done with no PR or
|
|
1304
|
+
// its PR closed).
|
|
1305
|
+
if (!columns.some((c) => c.name === "task_ended_at"))
|
|
1306
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN task_ended_at TEXT`);
|
|
1307
|
+
db.exec(`
|
|
1308
|
+
CREATE TRIGGER IF NOT EXISTS sessions_task_ended_at
|
|
1309
|
+
AFTER UPDATE OF task_status ON sessions
|
|
1310
|
+
WHEN NEW.task_status IN ('dropped', 'done')
|
|
1311
|
+
AND OLD.task_status IS NOT NEW.task_status
|
|
1312
|
+
AND NEW.task_ended_at IS OLD.task_ended_at
|
|
1313
|
+
BEGIN
|
|
1314
|
+
UPDATE sessions SET task_ended_at = datetime('now') WHERE id = NEW.id;
|
|
1315
|
+
END
|
|
1316
|
+
`);
|
|
1317
|
+
db.exec(`
|
|
1318
|
+
CREATE TRIGGER IF NOT EXISTS sessions_merged_at
|
|
1319
|
+
AFTER UPDATE OF task_status ON sessions
|
|
1320
|
+
WHEN NEW.task_status = 'merged' AND OLD.task_status IS NOT 'merged'
|
|
1321
|
+
-- unless the same statement set it (an import keeps its own)
|
|
1322
|
+
AND NEW.merged_at IS OLD.merged_at
|
|
1323
|
+
BEGIN
|
|
1324
|
+
UPDATE sessions SET merged_at = datetime('now') WHERE id = NEW.id;
|
|
1325
|
+
END
|
|
1326
|
+
`);
|
|
1327
|
+
installChangeTriggers(db, TABLES_WATCHED_58);
|
|
1328
|
+
},
|
|
1329
|
+
},
|
|
1229
1330
|
];
|
|
1230
1331
|
|
|
1231
1332
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/types.ts
CHANGED
|
@@ -87,6 +87,10 @@ export interface Session {
|
|
|
87
87
|
pr_url: string | null;
|
|
88
88
|
pr_number: number | null;
|
|
89
89
|
pr_status: "open" | "merged" | "closed" | null;
|
|
90
|
+
// When task_status became merged (a trigger sets it, migration 58).
|
|
91
|
+
merged_at?: string | null;
|
|
92
|
+
// When it became dropped or done (migration 58).
|
|
93
|
+
task_ended_at?: string | null;
|
|
90
94
|
// Merge as soon as every gate passes (orchestrator/armed.ts), and the
|
|
91
95
|
// failure last reported while armed.
|
|
92
96
|
merge_armed_at?: string | null;
|
|
@@ -97,6 +101,8 @@ export interface Session {
|
|
|
97
101
|
rebased_to?: string | null;
|
|
98
102
|
// The PR head the task was last asked to rebase by hand.
|
|
99
103
|
rebase_asked_head?: string | null;
|
|
104
|
+
// When its PR's reviews and comments started being watched (pr-wake.ts).
|
|
105
|
+
pr_wake_since?: string | null;
|
|
100
106
|
// Orchestration fields
|
|
101
107
|
conductor_session_id: string | null;
|
|
102
108
|
worker_task: string | null;
|
|
@@ -204,6 +210,9 @@ export interface Workspace {
|
|
|
204
210
|
orch_paused_at: string | null;
|
|
205
211
|
// Tasks running at once before new ones queue (lib/tasks/queue.ts); null: no limit.
|
|
206
212
|
max_running_tasks: number | null;
|
|
213
|
+
// Tasks are messaged about failing checks and reviews on their PRs
|
|
214
|
+
// (orchestrator/pr-wake.ts).
|
|
215
|
+
wake_tasks_on_pr: boolean;
|
|
207
216
|
created_at: string;
|
|
208
217
|
}
|
|
209
218
|
|
package/lib/notify/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { db, type Session } from "../db";
|
|
9
9
|
import { Limiter, type Outcome } from "./limiter";
|
|
10
|
+
import { holdForBatch, msToNextSweep } from "../away/state";
|
|
10
11
|
import { selectNotifier, type TelegramConfig } from "./notifiers";
|
|
11
12
|
|
|
12
13
|
export type { Outcome } from "./limiter";
|
|
@@ -135,6 +136,12 @@ export async function notifyFrom(
|
|
|
135
136
|
if (from && !session) throw new Error("Unknown sender session");
|
|
136
137
|
const body = text.trim();
|
|
137
138
|
if (!body) throw new Error("Nothing to send");
|
|
139
|
+
if (
|
|
140
|
+
session?.role === "orchestrator" &&
|
|
141
|
+
session.workspace_id &&
|
|
142
|
+
holdForBatch(session.workspace_id, session.id, body)
|
|
143
|
+
)
|
|
144
|
+
return { state: "held", inMs: msToNextSweep() };
|
|
138
145
|
return sendPhone(
|
|
139
146
|
session ? `session:${session.id}` : "cli",
|
|
140
147
|
session ? `${session.name}: ${body}` : body
|
|
@@ -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:
|
|
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
|
-
|
|
89
|
+
ciKey(pr),
|
|
83
90
|
`${who}: ${ciWord(pr)}${pr.checks === "pass" ? " and settled" : ""}`
|
|
84
91
|
);
|
|
85
92
|
}
|
|
@@ -12,6 +12,7 @@ import { openAsks } from "./asks";
|
|
|
12
12
|
import { listRules } from "./rules";
|
|
13
13
|
import { shortId } from "./describe";
|
|
14
14
|
import { untrusted } from "./untrusted";
|
|
15
|
+
import { awayDigestLine } from "../away/brief";
|
|
15
16
|
|
|
16
17
|
// About four characters a token: close enough to cap a prompt.
|
|
17
18
|
export const CHARS_PER_TOKEN = 4;
|
|
@@ -23,7 +24,7 @@ const EXTERNAL_DAYS = 2;
|
|
|
23
24
|
|
|
24
25
|
// One line, cut to length. A cut that leaves a fence around another
|
|
25
26
|
// session's text open closes it, so what follows isn't read as inside it.
|
|
26
|
-
const clip = (text: string, cap = LINE_CAP) => {
|
|
27
|
+
export const clip = (text: string, cap = LINE_CAP) => {
|
|
27
28
|
const one = text.replace(/\s+/g, " ").trim();
|
|
28
29
|
if (one.length <= cap) return one;
|
|
29
30
|
const cut = `${one.slice(0, cap - 3)}...`;
|
|
@@ -53,21 +54,21 @@ interface TaskRow {
|
|
|
53
54
|
armed_at: string | null;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
interface CheckRow {
|
|
57
|
+
export interface CheckRow {
|
|
57
58
|
session_id: string;
|
|
58
59
|
sha: string;
|
|
59
60
|
kind: string;
|
|
60
61
|
status: string;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
interface FailureRow {
|
|
64
|
+
export interface FailureRow {
|
|
64
65
|
session_id: string;
|
|
65
66
|
gate: string;
|
|
66
67
|
count: number;
|
|
67
68
|
escalated_at: string | null;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
function checksBySession(workspaceId: string): Map<string, CheckRow[]> {
|
|
71
|
+
export function checksBySession(workspaceId: string): Map<string, CheckRow[]> {
|
|
71
72
|
// The newest verdict of each kind per session or PR.
|
|
72
73
|
const rows = db
|
|
73
74
|
.prepare(
|
|
@@ -85,7 +86,9 @@ function checksBySession(workspaceId: string): Map<string, CheckRow[]> {
|
|
|
85
86
|
return by;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
function failuresBySession(
|
|
89
|
+
export function failuresBySession(
|
|
90
|
+
workspaceId: string
|
|
91
|
+
): Map<string, FailureRow[]> {
|
|
89
92
|
const rows = db
|
|
90
93
|
.prepare(
|
|
91
94
|
`SELECT session_id, gate, count, escalated_at FROM orchestrator_gate_failures
|
|
@@ -98,7 +101,7 @@ function failuresBySession(workspaceId: string): Map<string, FailureRow[]> {
|
|
|
98
101
|
return by;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
function gateState(
|
|
104
|
+
export function gateState(
|
|
102
105
|
checks: CheckRow[] = [],
|
|
103
106
|
failures: FailureRow[] = []
|
|
104
107
|
): string {
|
|
@@ -321,8 +324,8 @@ export function buildDigest(
|
|
|
321
324
|
): string {
|
|
322
325
|
const checks = checksBySession(workspaceId);
|
|
323
326
|
const failures = failuresBySession(workspaceId);
|
|
324
|
-
const
|
|
325
|
-
|
|
327
|
+
const away = awayDigestLine();
|
|
328
|
+
const head = `## Where things stand (read from AgentOS's database as you started)${away ? `\n\n${away}` : ""}`;
|
|
326
329
|
const body = renderDigest(
|
|
327
330
|
[
|
|
328
331
|
rulesSection(workspaceId),
|
|
@@ -10,7 +10,14 @@ import { saveItem } from "../chat/store";
|
|
|
10
10
|
import type { ChatItem } from "../chat/events";
|
|
11
11
|
import { getOrchestrator } from "./home";
|
|
12
12
|
|
|
13
|
-
export type NoteKind =
|
|
13
|
+
export type NoteKind =
|
|
14
|
+
| "note"
|
|
15
|
+
| "brake"
|
|
16
|
+
| "escalation"
|
|
17
|
+
| "ask"
|
|
18
|
+
| "pause"
|
|
19
|
+
// What Away mode's sweep did (lib/away).
|
|
20
|
+
| "away";
|
|
14
21
|
|
|
15
22
|
export interface NoteRow {
|
|
16
23
|
id: number;
|