@saadnvd1/agent-os 0.3.474 → 0.3.477

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/lib/away/run.ts CHANGED
@@ -18,7 +18,6 @@ import { holdingEscalations } from "../orchestrator/gates";
18
18
  import { review } from "../orchestrator/review";
19
19
  import { repoOf } from "../orchestrator/repo";
20
20
  import { clip } from "../orchestrator/digest";
21
- import { sendMessage } from "../bus";
22
21
  import { notifyPhone } from "../notify";
23
22
  import { getProject } from "../projects";
24
23
  import { loadProjectConfig } from "../project-config";
@@ -29,6 +28,8 @@ import { auditId, auditsRoot, writeAtomic } from "../session-audit/store";
29
28
  import {
30
29
  activeAway,
31
30
  claimSweep,
31
+ reachedWorkspaces,
32
+ toldKey,
32
33
  endAway,
33
34
  fromSql,
34
35
  isExpired,
@@ -44,6 +45,7 @@ import {
44
45
  SWEEP_EVERY_MS,
45
46
  type Candidate,
46
47
  } from "./sweep";
48
+ import { agentLive, awayWindowMs, lastTurnAt } from "./activity";
47
49
  import { checkDeploy } from "./deploy";
48
50
  import { buildAwayReport, phoneSummary, reportMarkdown } from "./report";
49
51
  import { awayOnLine } from "./brief";
@@ -59,9 +61,61 @@ function orchestrated(): { workspaceId: string; orchestratorId: string }[] {
59
61
  .map((o) => ({ workspaceId: o.workspace_id!, orchestratorId: o.id }));
60
62
  }
61
63
 
62
- function tellOrchestrators(key: string, line: string, now: number): void {
63
- for (const { workspaceId } of orchestrated())
64
- queueEvent(workspaceId, key, null, line, now);
64
+ const sessionRow = (id: string) =>
65
+ db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
66
+ | Session
67
+ | undefined;
68
+
69
+ // Away mode reaches a workspace only through an orchestrator that's been
70
+ // working inside the window: one left alone isn't told or woken.
71
+ export function orchestratorActive(
72
+ orchestratorId: string,
73
+ now = Date.now()
74
+ ): boolean {
75
+ const s = sessionRow(orchestratorId);
76
+ if (!s) return false;
77
+ const turn = lastTurnAt(s);
78
+ return turn > 0 && now - turn <= awayWindowMs();
79
+ }
80
+
81
+ // Tells an active orchestrator Saad is away: once a period, or again when
82
+ // `resend` (the end moved). False when it isn't active (now), so it's
83
+ // left alone.
84
+ function tellAway(
85
+ away: AwayRow,
86
+ workspaceId: string,
87
+ orchestratorId: string,
88
+ now: number,
89
+ resend = false
90
+ ): boolean {
91
+ // Checked every time: a digest's own turn isn't activity, so Away can't
92
+ // keep an orchestrator going that nothing else has.
93
+ if (!orchestratorActive(orchestratorId, now)) return false;
94
+ const told = reachedWorkspaces(away.id).has(workspaceId);
95
+ // One transaction: a crash can't leave the marker set with the event
96
+ // never queued.
97
+ db.transaction(() => {
98
+ if (!told) {
99
+ db.prepare(
100
+ `INSERT OR IGNORE INTO away_actions (away_id, workspace_id, subject, kind, key)
101
+ VALUES (?, ?, ?, 'told', ?)`
102
+ ).run(away.id, workspaceId, orchestratorId, toldKey(workspaceId));
103
+ addNote(
104
+ workspaceId,
105
+ `Saad turned Away on, ${untilText(away, now)}.`,
106
+ "away"
107
+ );
108
+ }
109
+ if (!told || resend)
110
+ queueEvent(
111
+ workspaceId,
112
+ `away:${away.id}`,
113
+ null,
114
+ awayOnLine(away, now),
115
+ now
116
+ );
117
+ })();
118
+ return true;
65
119
  }
66
120
 
67
121
  // In Settings > Audits under "away", named for when it ended (local time)
@@ -79,14 +133,8 @@ export function turnAwayOn(
79
133
  ): AwayRow {
80
134
  const { row, started } = startAway(input, now);
81
135
  // Sent again when the end moves, so the orchestrator has the new one.
82
- tellOrchestrators(`away:${row.id}`, awayOnLine(row, now), now);
83
- if (started)
84
- for (const { workspaceId } of orchestrated())
85
- addNote(
86
- workspaceId,
87
- `Saad turned Away on, ${untilText(row, now)}.`,
88
- "away"
89
- );
136
+ for (const { workspaceId, orchestratorId } of orchestrated())
137
+ tellAway(row, workspaceId, orchestratorId, now, !started);
90
138
  return row;
91
139
  }
92
140
 
@@ -101,7 +149,11 @@ export function turnAwayOff(
101
149
  const done = db.transaction(() => {
102
150
  const row = endAway(by, now);
103
151
  if (!row) return null;
104
- const report = buildAwayReport(row, deployConfigured);
152
+ const report = buildAwayReport(
153
+ row,
154
+ deployConfigured,
155
+ reachedWorkspaces(row.id)
156
+ );
105
157
  saveReport(row.id, report);
106
158
  return { ended: row, report };
107
159
  })();
@@ -116,13 +168,14 @@ export function turnAwayOff(
116
168
  by === "expired"
117
169
  ? "Away mode ran out: Saad may be back. Go back to reacting to events; don't drive interactive sessions."
118
170
  : "Saad is back: Away mode is off. Go back to reacting to events.";
119
- tellOrchestrators(`back:${ended.id}`, line, now);
120
- for (const { workspaceId } of orchestrated())
171
+ for (const workspaceId of reachedWorkspaces(ended.id)) {
172
+ queueEvent(workspaceId, `away-back:${ended.id}`, null, line, now);
121
173
  addNote(
122
174
  workspaceId,
123
175
  by === "expired" ? "Away ran out." : "Saad turned Away off.",
124
176
  "away"
125
177
  );
178
+ }
126
179
  // What came up since the last sweep's batch (a held `aos notify` is in
127
180
  // no report), then the summary. A second later, so the window takes
128
181
  // everything up to now.
@@ -175,21 +228,6 @@ function actedKeys(awayId: number, workspaceId: string): Set<string> {
175
228
  );
176
229
  }
177
230
 
178
- function nudgeCounts(awayId: number, workspaceId: string): Map<string, number> {
179
- const rows = db
180
- .prepare(
181
- `SELECT subject, COUNT(*) AS n FROM away_actions
182
- WHERE away_id = ? AND workspace_id = ? AND kind = 'nudge' GROUP BY subject`
183
- )
184
- .all(awayId, workspaceId) as { subject: string; n: number }[];
185
- return new Map(rows.map((r) => [r.subject, r.n]));
186
- }
187
-
188
- const sessionRow = (id: string) =>
189
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
190
- | Session
191
- | undefined;
192
-
193
231
  async function dirtySessions(ids: string[]): Promise<Set<string>> {
194
232
  const dirty = new Set<string>();
195
233
  // A few at a time: a burst of spawns is what loads the machine.
@@ -211,29 +249,19 @@ async function dirtySessions(ids: string[]): Promise<Set<string>> {
211
249
  return dirty;
212
250
  }
213
251
 
214
- async function act(
252
+ // A lost review run again: a reviewer, never a message to the session.
253
+ async function rerunReview(
215
254
  away: AwayRow,
216
255
  workspaceId: string,
217
- orchestratorId: string,
218
256
  c: Candidate
219
- ): Promise<void> {
220
- if (!claim(away, workspaceId, c.sessionId, c.kind, c.key)) return;
257
+ ): Promise<string | null> {
258
+ if (!claim(away, workspaceId, c.sessionId, c.kind, c.key)) return null;
221
259
  try {
222
- if (c.kind === "review") await review(workspaceId, c.sessionId);
223
- else
224
- await sendMessage({
225
- fromId: orchestratorId,
226
- to: c.sessionId,
227
- body: c.message!,
228
- });
229
- addNote(workspaceId, `Away sweep: ${c.log}`, "away");
260
+ await review(workspaceId, c.sessionId);
261
+ return c.line;
230
262
  } catch (error) {
231
263
  const why = error instanceof Error ? error.message : String(error);
232
- addNote(
233
- workspaceId,
234
- `Away sweep: ${c.log} It didn't go through: ${clip(why, 200)}`,
235
- "away"
236
- );
264
+ return `${c.line.replace(/; AgentOS ran it again\.$/, "")}; running it again failed: ${clip(why, 200)}`;
237
265
  }
238
266
  }
239
267
 
@@ -257,12 +285,26 @@ async function mergeCommit(repo: string, pr: number): Promise<string | null> {
257
285
  return SHA.test(sha) ? sha : null;
258
286
  }
259
287
 
260
- // Each task merged while away, until its deploy is decided.
288
+ // Each task merged while away, until its deploy is decided. Returns the
289
+ // digest's line for each one decided now.
290
+ // Claimed with the digest that reports it, so a crash in between can't
291
+ // leave a deploy marked as reported that never was.
292
+ class EmptyDigest extends Error {}
293
+
294
+ interface DeployFinding {
295
+ subject: string;
296
+ kind: string;
297
+ key: string;
298
+ detail: string;
299
+ line: string;
300
+ }
301
+
261
302
  async function checkDeploys(
262
303
  away: AwayRow,
263
304
  workspaceId: string,
264
305
  now: number
265
- ): Promise<void> {
306
+ ): Promise<DeployFinding[]> {
307
+ const found: DeployFinding[] = [];
266
308
  const merged = db
267
309
  .prepare(
268
310
  `SELECT s.* FROM sessions s JOIN projects p ON p.id = s.project_id
@@ -294,27 +336,55 @@ async function checkDeploys(
294
336
  : verdict.state === "failed"
295
337
  ? "deploy_failed"
296
338
  : "deploy_unchecked";
297
- if (!claim(away, workspaceId, s.id, kind, `deploy:${s.id}`, verdict.detail))
339
+ const key = `deploy:${s.id}`;
340
+ if (verdict.state === "unchecked") {
341
+ claim(away, workspaceId, s.id, kind, key, verdict.detail);
298
342
  continue;
299
- if (verdict.state === "unchecked") continue;
300
- const line = `task ${s.name}: PR #${s.pr_number ?? "?"} merged; ${verdict.state === "ok" ? verdict.detail : `deploy failed: ${verdict.detail}`}`;
301
- addNote(workspaceId, `Away sweep: ${line}`, "away");
302
- if (verdict.state === "failed")
303
- queueEvent(workspaceId, `deploy:${s.id}`, s.id, line, now);
343
+ }
344
+ found.push({
345
+ subject: s.id,
346
+ kind,
347
+ key,
348
+ detail: verdict.detail,
349
+ line: `task ${s.name}: PR #${s.pr_number ?? "?"} merged; ${verdict.state === "ok" ? verdict.detail : `deploy failed: ${verdict.detail}`}`,
350
+ });
304
351
  }
352
+ return found;
305
353
  }
306
354
 
355
+ const DIGEST_HEAD =
356
+ "Away sweep: what changed in this workspace since the last digest. Sessions not listed had no real activity inside the window or nothing new; leave them alone.";
357
+
358
+ const candidateLabel: Record<Candidate["kind"], string> = {
359
+ stalled: "stalled",
360
+ commit: "uncommitted, no PR",
361
+ review: "review",
362
+ wrapup: "interactive",
363
+ };
364
+
365
+ // Sweeps a workspace whose orchestrator is active: lost reviews run again,
366
+ // deploys checked, and what's new goes to the orchestrator as one digest
367
+ // event. No other session is ever messaged. Returns the digest, if one went.
307
368
  export async function sweepWorkspace(
308
369
  away: AwayRow,
309
370
  workspaceId: string,
310
371
  orchestratorId: string,
311
372
  now = Date.now()
312
- ): Promise<void> {
373
+ ): Promise<string | null> {
313
374
  // Paused means it acts on nothing, the sweep included.
314
- if (isPaused(workspaceId)) return;
375
+ if (isPaused(workspaceId)) return null;
376
+ if (!tellAway(away, workspaceId, orchestratorId, now)) return null;
315
377
  const facts = await sessionFacts(workspaceId);
316
378
  const acted = actedKeys(away.id, workspaceId);
317
- const dirty = await dirtySessions(needsGitCheck(facts, now, acted));
379
+ const rows = new Map(
380
+ facts.map((f) => [f.id, sessionRow(f.id)] as const).filter(([, s]) => s)
381
+ ) as Map<string, Session>;
382
+ const turns = new Map([...rows].map(([id, s]) => [id, lastTurnAt(s)]));
383
+ const live = new Set(
384
+ facts
385
+ .filter((f) => !f.task && rows.has(f.id) && agentLive(rows.get(f.id)!))
386
+ .map((f) => f.id)
387
+ );
318
388
  const openOn = new Set(
319
389
  (
320
390
  db
@@ -329,13 +399,19 @@ export async function sweepWorkspace(
329
399
  .filter((f) => openOn.has(f.id) || holdingEscalations(f.id).length)
330
400
  .map((f) => f.id)
331
401
  );
332
- const { act: todo, firstSeen } = pickCandidates({
402
+ const windowMs = awayWindowMs();
403
+ const dirty = await dirtySessions(
404
+ needsGitCheck({ facts, now, turns, windowMs, live, held, acted })
405
+ );
406
+ const { flags, reviews, firstSeen } = pickCandidates({
333
407
  now,
334
408
  facts,
409
+ turns,
410
+ windowMs,
411
+ live,
335
412
  dirty,
336
413
  held,
337
414
  acted,
338
- nudges: nudgeCounts(away.id, workspaceId),
339
415
  reviewOf: (id, sha) => {
340
416
  const c = getCheck(id, sha, "review");
341
417
  return c ? { status: c.status, at: fromSql(c.created_at) } : null;
@@ -343,8 +419,50 @@ export async function sweepWorkspace(
343
419
  });
344
420
  for (const key of firstSeen)
345
421
  claim(away, workspaceId, key.split(":")[1], "seen", key);
346
- for (const c of todo) await act(away, workspaceId, orchestratorId, c);
347
- await checkDeploys(away, workspaceId, now);
422
+ const lines: string[] = [];
423
+ for (const c of reviews) {
424
+ const line = await rerunReview(away, workspaceId, c);
425
+ if (line) lines.push(`- review: ${line}`);
426
+ }
427
+ const deploys = await checkDeploys(away, workspaceId, now);
428
+ if (!flags.length && !deploys.length && !lines.length) return null;
429
+ // Claimed like the rest, so a period's digests are counted and a sweep
430
+ // that ended meanwhile sends none. The digest, its event and the
431
+ // candidates it reports commit together.
432
+ const send = db.transaction((): string => {
433
+ if (
434
+ !claim(
435
+ away,
436
+ workspaceId,
437
+ orchestratorId,
438
+ "digest",
439
+ `digest:${workspaceId}:${now}`
440
+ )
441
+ )
442
+ throw new EmptyDigest();
443
+ const items = [...lines];
444
+ for (const c of flags)
445
+ if (claim(away, workspaceId, c.sessionId, c.kind, c.key))
446
+ items.push(`- ${candidateLabel[c.kind]}: ${c.line}`);
447
+ for (const d of deploys)
448
+ if (claim(away, workspaceId, d.subject, d.kind, d.key, d.detail))
449
+ items.push(`- deploy: ${d.line}`);
450
+ if (!items.length) throw new EmptyDigest();
451
+ const text = [DIGEST_HEAD, ...items].join("\n");
452
+ queueEvent(workspaceId, `away-digest:${away.id}:${now}`, null, text, now);
453
+ addNote(
454
+ workspaceId,
455
+ `Away sweep: sent the orchestrator a digest of ${items.length} ${items.length === 1 ? "item" : "items"}.`,
456
+ "away"
457
+ );
458
+ return text;
459
+ });
460
+ try {
461
+ return send();
462
+ } catch (e) {
463
+ if (e instanceof EmptyDigest) return null;
464
+ throw e;
465
+ }
348
466
  }
349
467
 
350
468
  // One phone message for what came up since the last: new asks across
package/lib/away/state.ts CHANGED
@@ -48,6 +48,23 @@ export function getAway(id: number): AwayRow | null {
48
48
  );
49
49
  }
50
50
 
51
+ // The away_actions row marking a workspace Away mode reached this period:
52
+ // its orchestrator was active and was told (./run). Keys are unique per
53
+ // period, so it names the workspace.
54
+ export const toldKey = (workspaceId: string) => `told:${workspaceId}`;
55
+
56
+ export function reachedWorkspaces(awayId: number): Set<string> {
57
+ return new Set(
58
+ (
59
+ db
60
+ .prepare(
61
+ `SELECT workspace_id FROM away_actions WHERE away_id = ? AND kind = 'told'`
62
+ )
63
+ .all(awayId) as { workspace_id: string }[]
64
+ ).map((r) => r.workspace_id)
65
+ );
66
+ }
67
+
51
68
  export const isExpired = (row: AwayRow, now = Date.now()) =>
52
69
  !!row.expires_at && fromSql(row.expires_at) <= now;
53
70
 
package/lib/away/sweep.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  /**
2
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.
3
+ * (session facts, real activity, git status, stored review verdicts), never
4
+ * by a model reading the workspace. Pure: ./run gathers the inputs and acts.
5
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.
6
+ * The sweep messages no session. What it finds goes to the workspace's
7
+ * orchestrator as one digest, which decides what needs doing:
8
+ * - a task with no real activity for STALL_MS and nothing to wait on;
9
+ * - a task idle with uncommitted work and no PR;
10
+ * - an interactive session (Saad's) idle with uncommitted work, whose
11
+ * agent is still up: the orchestrator may ask it once to wrap up.
12
+ * Only sessions with a real turn inside the window are listed; older ones
13
+ * are left alone. Each is listed once per state (per period, for an
14
+ * interactive one).
15
+ *
16
+ * A PR whose CI settled green with no review of its head, or one that was
17
+ * lost (errored, or cut off and stale), has its review run again, once the
18
+ * sweep has seen it that way twice, so the orchestrator had its turn. That
19
+ * runs a reviewer, not the session, so it isn't held to the window.
15
20
  */
16
21
 
17
22
  import { isFinished } from "../tasks/state";
@@ -20,21 +25,18 @@ import type { SessionFacts } from "../orchestrator/facts";
20
25
  export const SWEEP_EVERY_MS = 10 * 60 * 1000;
21
26
  export const STALL_MS = 30 * 60 * 1000;
22
27
  export const IDLE_DIRTY_MS = 15 * 60 * 1000;
23
- export const MAX_NUDGES = 2;
24
28
  // A review "running" this long was cut off (as clearStaleRunning has it).
25
29
  export const STALE_REVIEW_MS = 30 * 60 * 1000;
26
30
 
27
- export type CandidateKind = "nudge" | "commit" | "review" | "wrapup";
31
+ export type CandidateKind = "stalled" | "commit" | "review" | "wrapup";
28
32
 
29
33
  export interface Candidate {
30
34
  kind: CandidateKind;
31
35
  sessionId: string;
32
- // Acted on once per Away period.
36
+ // Listed (or run, for a review) once per Away period.
33
37
  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
+ // Its line in the orchestrator's digest and the decision log.
39
+ line: string;
38
40
  }
39
41
 
40
42
  export interface ReviewSeen {
@@ -45,6 +47,12 @@ export interface ReviewSeen {
45
47
  export interface SweepView {
46
48
  now: number;
47
49
  facts: SessionFacts[];
50
+ // Each session's last real turn, in ms (./activity).
51
+ turns: ReadonlyMap<string, number>;
52
+ // Older than this is left alone.
53
+ windowMs: number;
54
+ // Interactive sessions whose agent is up.
55
+ live: ReadonlySet<string>;
48
56
  // Sessions with uncommitted changes in their worktree.
49
57
  dirty: ReadonlySet<string>;
50
58
  // The stored review verdict of a session's commit, if any.
@@ -53,17 +61,24 @@ export interface SweepView {
53
61
  held: ReadonlySet<string>;
54
62
  // Keys acted on (or seen, for reviews) this period.
55
63
  acted: ReadonlySet<string>;
56
- // Nudges sent to each session this period.
57
- nudges: ReadonlyMap<string, number>;
58
64
  }
59
65
 
60
66
  const minutes = (ms: number) => Math.floor(ms / 60000);
61
67
 
62
- const BLOCKED_HINT =
63
- "If only Saad can unblock it, end with a line starting BLOCKED: that says what you need.";
68
+ const turnOf = (f: SessionFacts, v: Pick<SweepView, "turns">) =>
69
+ v.turns.get(f.id) ?? 0;
70
+
71
+ // Real activity inside the window: anything older is never listed.
72
+ export const recent = (
73
+ f: SessionFacts,
74
+ v: Pick<SweepView, "now" | "turns" | "windowMs">
75
+ ) => {
76
+ const turn = turnOf(f, v);
77
+ return turn > 0 && v.now - turn <= v.windowMs;
78
+ };
64
79
 
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.
80
+ // Live work with an agent to answer: not stopped, not finished, not a
81
+ // linked machine's copy, not setting up, not waiting on someone already.
67
82
  function workable(f: SessionFacts): boolean {
68
83
  if (f.status === "dead" || f.status === "waiting" || f.needsInput)
69
84
  return false;
@@ -79,62 +94,63 @@ function workable(f: SessionFacts): boolean {
79
94
  return true;
80
95
  }
81
96
 
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
97
+ const commitKey = (f: SessionFacts, turn: number) => `commit:${f.id}:${turn}`;
98
+ const wrapupKey = (f: SessionFacts) => `wrapup:${f.id}`;
99
+ export const seenKey = (id: string, sha: string) => `review-seen:${id}:${sha}`;
100
+
101
+ // Who could be listed at all.
102
+ function eligible(f: SessionFacts, v: GitView): boolean {
103
+ if (!workable(f) || !recent(f, v)) return false;
104
+ return f.task ? !v.held.has(f.id) : v.live.has(f.id);
105
+ }
106
+
107
+ type GitView = Pick<
108
+ SweepView,
109
+ "facts" | "now" | "turns" | "windowMs" | "live" | "held" | "acted"
110
+ >;
111
+
112
+ // Sessions worth a `git status`: recent, quiet long enough that
113
+ // uncommitted work would matter, and not already listed for it. Keeps the
84
114
  // sweep from reading every worktree.
85
- export function needsGitCheck(
86
- facts: SessionFacts[],
87
- now: number,
88
- acted: ReadonlySet<string>
89
- ): string[] {
90
- return facts
115
+ export function needsGitCheck(v: GitView): string[] {
116
+ return v.facts
91
117
  .filter(
92
118
  (f) =>
93
- workable(f) &&
119
+ eligible(f, v) &&
94
120
  f.status === "idle" &&
95
121
  !f.task?.pr &&
96
- now - f.lastActive >= IDLE_DIRTY_MS &&
97
- !acted.has(f.task ? commitKey(f) : wrapupKey(f))
122
+ v.now - turnOf(f, v) >= IDLE_DIRTY_MS &&
123
+ !v.acted.has(f.task ? commitKey(f, turnOf(f, v)) : wrapupKey(f))
98
124
  )
99
125
  .map((f) => f.id);
100
126
  }
101
127
 
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
128
  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;
129
+ const pr = f.task!.pr;
130
+ const turn = turnOf(f, v);
131
+ const quiet = v.now - turn;
110
132
  const who = `task ${f.name}`;
111
133
 
112
134
  if (!pr && f.status === "idle" && v.dirty.has(f.id)) {
113
- const key = commitKey(f);
135
+ const key = commitKey(f, turn);
114
136
  if (v.acted.has(key)) return null;
115
137
  return {
116
138
  kind: "commit",
117
139
  sessionId: f.id,
118
140
  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.`,
141
+ line: `${who} (${f.id}): idle ${minutes(quiet)}m with uncommitted work and no PR.`,
121
142
  };
122
143
  }
123
144
 
124
145
  const ciFailing = pr?.state === "OPEN" && pr.checks === "fail";
125
146
  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}`;
147
+ const key = `stalled:${f.id}:${turn}`;
128
148
  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
149
  return {
133
- kind: "nudge",
150
+ kind: "stalled",
134
151
  sessionId: f.id,
135
152
  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.`,
153
+ line: `${who} (${f.id}): no activity for ${minutes(quiet)}m${ciFailing ? `, CI failing on PR #${pr!.number}` : ", no PR yet"}.`,
138
154
  };
139
155
  }
140
156
  return null;
@@ -169,7 +185,7 @@ function reviewCandidate(f: SessionFacts, v: SweepView): Candidate | null {
169
185
  kind: "review",
170
186
  sessionId: f.id,
171
187
  key,
172
- log: `task ${f.name}: the review of PR #${pr.number} at ${pr.head.slice(0, 7)} was ${why}; ran it again.`,
188
+ line: `task ${f.name}: the review of PR #${pr.number} at ${pr.head.slice(0, 7)} was ${why}; AgentOS ran it again.`,
173
189
  };
174
190
  }
175
191
 
@@ -181,36 +197,34 @@ function wrapupCandidate(f: SessionFacts, v: SweepView): Candidate | null {
181
197
  kind: "wrapup",
182
198
  sessionId: f.id,
183
199
  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.`,
200
+ line: `${f.name} (${f.id}): Saad's own session, idle ${minutes(v.now - turnOf(f, v))}m with uncommitted work.`,
187
201
  };
188
202
  }
189
203
 
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`).
204
+ // What's new this sweep: lines for the orchestrator's digest, and the
205
+ // reviews to run again. A review candidate seen for the first time is
206
+ // returned only once its seen-key is in `acted`; the caller records the
207
+ // seen-key otherwise (`firstSeen`).
193
208
  export function pickCandidates(v: SweepView): {
194
- act: Candidate[];
209
+ flags: Candidate[];
210
+ reviews: Candidate[];
195
211
  firstSeen: string[];
196
212
  } {
197
- const act: Candidate[] = [];
213
+ const flags: Candidate[] = [];
214
+ const reviews: Candidate[] = [];
198
215
  const firstSeen: string[] = [];
199
216
  for (const f of v.facts) {
200
217
  if (f.task) {
201
218
  const review = reviewCandidate(f, v);
202
219
  if (review) {
203
220
  const seen = seenKey(f.id, f.task.pr!.head!);
204
- if (v.acted.has(seen)) act.push(review);
221
+ if (v.acted.has(seen)) reviews.push(review);
205
222
  else firstSeen.push(seen);
206
223
  }
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
224
  }
225
+ if (!eligible(f, v)) continue;
226
+ const c = f.task ? taskCandidate(f, v) : wrapupCandidate(f, v);
227
+ if (c) flags.push(c);
214
228
  }
215
- return { act, firstSeen };
229
+ return { flags, reviews, firstSeen };
216
230
  }
@@ -0,0 +1,16 @@
1
+ // The Away window's bounds, in minutes, for the server (./activity) and
2
+ // the Settings form alike. Client-safe: no database.
3
+ export const DEFAULT_WINDOW_MINUTES = 60;
4
+ export const MIN_WINDOW_MINUTES = 10;
5
+ export const MAX_WINDOW_MINUTES = 24 * 60;
6
+
7
+ // The form's text as minutes, or null when it's out of bounds.
8
+ export function parseWindow(text: string): number | null {
9
+ const n = Number(text.trim());
10
+ return text.trim() &&
11
+ Number.isInteger(n) &&
12
+ n >= MIN_WINDOW_MINUTES &&
13
+ n <= MAX_WINDOW_MINUTES
14
+ ? n
15
+ : null;
16
+ }
@@ -80,6 +80,9 @@ export interface ChatOrigin {
80
80
  body?: string;
81
81
  // Lines of an event batch that are the reader's answers to asks.
82
82
  decided?: string[];
83
+ // Only Away mode's own lines: the turn it starts isn't anyone's activity
84
+ // (lib/away/activity).
85
+ away?: true;
83
86
  }
84
87
 
85
88
  // Who sent a message, carried with it through the queue and the worker.