@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.
Files changed (45) hide show
  1. package/README.md +53 -1
  2. package/app/api/away/route.ts +56 -0
  3. package/app/api/away/seen/route.ts +12 -0
  4. package/app/api/health/route.ts +9 -0
  5. package/app/api/workspaces/[id]/route.ts +8 -1
  6. package/app/page.tsx +2 -0
  7. package/components/Away/AwayReport.tsx +16 -0
  8. package/components/Away/AwayReportDialog.tsx +193 -0
  9. package/components/Away/AwayToggle.tsx +91 -0
  10. package/components/Away/time.test.ts +20 -0
  11. package/components/Away/time.ts +32 -0
  12. package/components/Away/useAwayCommands.ts +43 -0
  13. package/components/Chat/Messages.tsx +1 -0
  14. package/components/CommandPalette/useAppCommands.ts +2 -0
  15. package/components/Pane/MobileTabBar.tsx +14 -1
  16. package/components/SessionList/SidebarHeader.tsx +2 -0
  17. package/components/Workspaces/WorkspaceSettings.tsx +9 -1
  18. package/data/away/index.ts +73 -0
  19. package/data/push/topics.ts +4 -0
  20. package/data/workspaces/index.ts +6 -1
  21. package/lib/away/brief.ts +23 -0
  22. package/lib/away/deploy.ts +173 -0
  23. package/lib/away/report.ts +398 -0
  24. package/lib/away/run.ts +445 -0
  25. package/lib/away/state.ts +201 -0
  26. package/lib/away/sweep.ts +216 -0
  27. package/lib/away/view.ts +51 -0
  28. package/lib/chat/events.ts +1 -1
  29. package/lib/db/changes.ts +3 -0
  30. package/lib/db/migrations.ts +101 -0
  31. package/lib/db/types.ts +9 -0
  32. package/lib/notify/index.ts +7 -0
  33. package/lib/orchestrator/brief.ts +2 -0
  34. package/lib/orchestrator/conditions.ts +10 -3
  35. package/lib/orchestrator/digest.ts +11 -8
  36. package/lib/orchestrator/notes.ts +8 -1
  37. package/lib/orchestrator/pr-wake.ts +385 -0
  38. package/lib/orchestrator/watcher.ts +12 -2
  39. package/lib/project-config/agentos.schema.json +24 -0
  40. package/lib/project-config/schema.ts +17 -0
  41. package/lib/tasks/gh.ts +2 -0
  42. package/lib/tasks/pr-activity.ts +295 -0
  43. package/lib/tasks/state.ts +9 -1
  44. package/lib/workspaces.ts +8 -2
  45. 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
+ }
@@ -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
+ }
@@ -192,7 +192,7 @@ export type ChatItem =
192
192
  | (Base & {
193
193
  kind: "note";
194
194
  text: string;
195
- tone: "note" | "brake" | "escalation" | "ask" | "pause";
195
+ tone: "note" | "brake" | "escalation" | "ask" | "pause" | "away";
196
196
  })
197
197
  | (Base & {
198
198
  kind: "approval";
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(
@@ -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
 
@@ -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: `${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
  }
@@ -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(workspaceId: string): Map<string, FailureRow[]> {
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 head =
325
- "## Where things stand (read from AgentOS's database as you started)";
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 = "note" | "brake" | "escalation" | "ask" | "pause";
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;