@saadnvd1/agent-os 0.3.474 → 0.3.476
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 +20 -14
- package/app/api/away/settings/route.ts +26 -0
- package/components/Away/AwayWindowForm.tsx +91 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/away/index.ts +36 -0
- package/lib/away/activity.ts +147 -0
- package/lib/away/brief.ts +12 -4
- package/lib/away/report.ts +23 -9
- package/lib/away/run.ts +180 -62
- package/lib/away/state.ts +17 -0
- package/lib/away/sweep.ts +84 -70
- package/lib/away/window-limits.ts +16 -0
- package/lib/chat/events.ts +3 -0
- package/lib/models/roles.ts +1 -1
- package/lib/orchestrator/deliver.ts +2 -0
- package/lib/orchestrator/digest.ts +1 -1
- package/lib/orchestrator/events.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ wait under **Queued** with their place in line and start on their own, and
|
|
|
35
35
|
|
|
36
36
|
### Each kind of work on the right model
|
|
37
37
|
|
|
38
|
-
Planning
|
|
38
|
+
Planning, gate reviews and the orchestrator run on Opus, implementation and
|
|
39
39
|
scope checks on Sonnet, titles and summaries on Haiku, each editable in
|
|
40
40
|
Settings → Models and per workspace. A task plans on one model and
|
|
41
41
|
implements on another in the same conversation, can be resumed on any
|
|
@@ -459,7 +459,7 @@ Each kind of work AgentOS launches an agent for runs on its own model, set in
|
|
|
459
459
|
| Task planning (a task's plan step) | `anthropic/claude-opus-5-5` |
|
|
460
460
|
| Gate review | `anthropic/claude-opus-5-5` |
|
|
461
461
|
| Task implementation | `anthropic/claude-sonnet-5-5` |
|
|
462
|
-
| Orchestrator | `anthropic/claude-
|
|
462
|
+
| Orchestrator | `anthropic/claude-opus-5-5` |
|
|
463
463
|
| Scope check | `anthropic/claude-sonnet-5-5` |
|
|
464
464
|
| Interactive sessions | `anthropic/claude-opus-5-5` |
|
|
465
465
|
| Chat & summary helpers (titles, summaries, PR text) | `anthropic/claude-haiku-4-5-20251001` |
|
|
@@ -1174,23 +1174,29 @@ reset` yourself in a terminal on the machine: it refuses inside an
|
|
|
1174
1174
|
sidebar header or ⌘K ("Away for 4 hours"), for 1 hour, 4 hours, until
|
|
1175
1175
|
morning (8 AM) or until you're back. It's kept in `away_periods` (who set
|
|
1176
1176
|
it, when, when it ends), the cup turns amber while it's on (with an amber dot on the phone's menu button), and it
|
|
1177
|
-
ends on its own at its time.
|
|
1177
|
+
ends on its own at its time. It works only through orchestrators that
|
|
1178
|
+
had a real turn (yours or theirs, not an AgentOS event or a status poll)
|
|
1179
|
+
in the last hour, set in Settings → Workspaces → Away mode; the rest
|
|
1180
|
+
aren't told or woken. Each active orchestrator gets an event, and a line
|
|
1178
1181
|
leading its digest, saying you're away and to follow work through rather
|
|
1179
1182
|
than only react. Every 10 minutes a sweep, written as code with no model
|
|
1180
|
-
scanning,
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1183
|
+
scanning, runs a review again when a PR is green and settled but its
|
|
1184
|
+
review never started or was lost (once seen on two sweeps, so the
|
|
1185
|
+
orchestrator had its turn), checks after each merge whether the project's
|
|
1186
|
+
`deploy` went live and the server came back, and sends the orchestrator
|
|
1187
|
+
one digest of what's new: tasks quiet 30 minutes with no PR (or failing
|
|
1188
|
+
CI), idle tasks with uncommitted work and no PR, and your own sessions
|
|
1189
|
+
idle with uncommitted work. Only sessions with a real turn inside the
|
|
1190
|
+
window are listed, never stopped, finished or archived ones, and nothing
|
|
1191
|
+
goes out when nothing changed. The sweep messages no session itself: the
|
|
1192
|
+
orchestrator decides what needs work, and asks one of your sessions at
|
|
1193
|
+
most once to wrap up. Away's own messages don't count as activity. Paused
|
|
1189
1194
|
workspaces aren't swept. Hard lines still become asks, and while you're
|
|
1190
1195
|
away the asks raised, failed deploys and any `aos notify` an orchestrator
|
|
1191
1196
|
sends reach your phone as one message per sweep. Turning it off (or its running out) builds "While you were
|
|
1192
|
-
away" from the database
|
|
1193
|
-
|
|
1197
|
+
away" from the database, for the workspaces Away reached: what merged and
|
|
1198
|
+
deployed with PR links, what the sweep flagged that's still in flight and
|
|
1199
|
+
which gate or blocker holds it, what failed, was dropped
|
|
1194
1200
|
or was escalated, and what waits on you (an explicit "none"). It opens on
|
|
1195
1201
|
whichever device is open until you dismiss it, a short summary goes to
|
|
1196
1202
|
your phone, and the report stays in Settings → Audits (`away/`).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
+
import { awayWindowMinutes, setAwayWindowMinutes } from "@/lib/away/activity";
|
|
4
|
+
|
|
5
|
+
// GET /api/away/settings - how recent a session's last real turn must be
|
|
6
|
+
// for Away mode to touch it (lib/away/activity).
|
|
7
|
+
export function GET() {
|
|
8
|
+
return NextResponse.json({ windowMinutes: awayWindowMinutes() });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// PUT /api/away/settings - { windowMinutes }. From a trusted place, as
|
|
12
|
+
// turning Away on is.
|
|
13
|
+
export async function PUT(request: NextRequest) {
|
|
14
|
+
const gate = requireApprover(request);
|
|
15
|
+
if (!gate.ok) return gate.response;
|
|
16
|
+
try {
|
|
17
|
+
const { windowMinutes } = (await request.json().catch(() => ({}))) as {
|
|
18
|
+
windowMinutes?: unknown;
|
|
19
|
+
};
|
|
20
|
+
setAwayWindowMinutes(windowMinutes);
|
|
21
|
+
return NextResponse.json({ windowMinutes: awayWindowMinutes() });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24
|
+
return NextResponse.json({ error: message }, { status: 400 });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import {
|
|
8
|
+
useAwaySettingsQuery,
|
|
9
|
+
useUpdateAwaySettings,
|
|
10
|
+
type AwaySettings,
|
|
11
|
+
} from "@/data/away";
|
|
12
|
+
import {
|
|
13
|
+
MAX_WINDOW_MINUTES,
|
|
14
|
+
MIN_WINDOW_MINUTES,
|
|
15
|
+
parseWindow,
|
|
16
|
+
} from "@/lib/away/window-limits";
|
|
17
|
+
|
|
18
|
+
// Settings → Workspaces: how recent a session's work must be for Away mode
|
|
19
|
+
// to touch it. App-wide.
|
|
20
|
+
export function AwayWindowForm() {
|
|
21
|
+
const { data, isPending, isError, error } = useAwaySettingsQuery();
|
|
22
|
+
if (isPending)
|
|
23
|
+
return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
|
|
24
|
+
if (isError)
|
|
25
|
+
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
26
|
+
return <Form key={data.windowMinutes} saved={data} />;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Form({ saved }: { saved: AwaySettings }) {
|
|
30
|
+
const update = useUpdateAwaySettings();
|
|
31
|
+
const [text, setText] = useState(String(saved.windowMinutes));
|
|
32
|
+
const minutes = parseWindow(text);
|
|
33
|
+
const error =
|
|
34
|
+
minutes === null
|
|
35
|
+
? `A whole number from ${MIN_WINDOW_MINUTES} to ${MAX_WINDOW_MINUTES}`
|
|
36
|
+
: null;
|
|
37
|
+
return (
|
|
38
|
+
<form
|
|
39
|
+
onSubmit={(e) => {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
if (minutes === null) return;
|
|
42
|
+
update.mutate(
|
|
43
|
+
{ windowMinutes: minutes },
|
|
44
|
+
{
|
|
45
|
+
onSuccess: () => toast.success("Away mode settings saved"),
|
|
46
|
+
onError: (err) => toast.error(err.message),
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
}}
|
|
50
|
+
className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
|
|
51
|
+
>
|
|
52
|
+
<h3 className="text-sm font-medium">Away mode</h3>
|
|
53
|
+
<p className="text-muted-foreground text-sm">
|
|
54
|
+
Away mode works through orchestrators that were busy in this window, and
|
|
55
|
+
only lists sessions with a real turn inside it. Older ones are left
|
|
56
|
+
alone: a message wakes an agent and re-reads its whole conversation.
|
|
57
|
+
</p>
|
|
58
|
+
<label className="flex items-center justify-between gap-3 text-sm">
|
|
59
|
+
<span className="text-muted-foreground">Recent means the last</span>
|
|
60
|
+
<span className="flex items-center gap-2">
|
|
61
|
+
<Input
|
|
62
|
+
type="number"
|
|
63
|
+
inputMode="numeric"
|
|
64
|
+
min={MIN_WINDOW_MINUTES}
|
|
65
|
+
max={MAX_WINDOW_MINUTES}
|
|
66
|
+
step={1}
|
|
67
|
+
value={text}
|
|
68
|
+
onChange={(e) => setText(e.target.value)}
|
|
69
|
+
aria-label="Away window, minutes"
|
|
70
|
+
aria-invalid={!!error}
|
|
71
|
+
className="h-11 w-24 text-base sm:h-9 md:text-sm"
|
|
72
|
+
/>
|
|
73
|
+
<span className="text-muted-foreground">min</span>
|
|
74
|
+
</span>
|
|
75
|
+
</label>
|
|
76
|
+
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
77
|
+
<div className="flex justify-end">
|
|
78
|
+
<Button
|
|
79
|
+
type="submit"
|
|
80
|
+
size="sm"
|
|
81
|
+
className="h-11 sm:h-8"
|
|
82
|
+
disabled={
|
|
83
|
+
!!error || minutes === saved.windowMinutes || update.isPending
|
|
84
|
+
}
|
|
85
|
+
>
|
|
86
|
+
Save
|
|
87
|
+
</Button>
|
|
88
|
+
</div>
|
|
89
|
+
</form>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -6,6 +6,7 @@ import { TaskLimitForm } from "./TaskLimitForm";
|
|
|
6
6
|
import { RulesForm } from "./RulesForm";
|
|
7
7
|
import { FreshStartForm } from "./FreshStartForm";
|
|
8
8
|
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
9
|
+
import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
|
|
9
10
|
|
|
10
11
|
// Settings > Workspaces: each workspace's own settings.
|
|
11
12
|
export function WorkspaceSettings() {
|
|
@@ -52,6 +53,7 @@ export function WorkspaceSettings() {
|
|
|
52
53
|
</section>
|
|
53
54
|
))}
|
|
54
55
|
<FreshStartForm />
|
|
56
|
+
<AwayWindowForm />
|
|
55
57
|
</div>
|
|
56
58
|
);
|
|
57
59
|
}
|
package/data/away/index.ts
CHANGED
|
@@ -71,3 +71,39 @@ export function awayEnds(d: AwayDuration, now = new Date()): Date | null {
|
|
|
71
71
|
if (now.getHours() >= 5) morning.setDate(morning.getDate() + 1);
|
|
72
72
|
return morning;
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
// How recent a session's last real turn must be for Away mode to touch it.
|
|
76
|
+
export interface AwaySettings {
|
|
77
|
+
windowMinutes: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const settingsKey = [...awayKeys.all, "settings"] as const;
|
|
81
|
+
|
|
82
|
+
async function json<T>(res: Response): Promise<T> {
|
|
83
|
+
const data = await res.json().catch(() => ({}));
|
|
84
|
+
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
|
|
85
|
+
return data;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function useAwaySettingsQuery() {
|
|
89
|
+
return useQuery({
|
|
90
|
+
queryKey: settingsKey,
|
|
91
|
+
queryFn: async () => json<AwaySettings>(await fetch("/api/away/settings")),
|
|
92
|
+
staleTime: 60_000,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function useUpdateAwaySettings() {
|
|
97
|
+
const queryClient = useQueryClient();
|
|
98
|
+
return useMutation({
|
|
99
|
+
mutationFn: async (settings: AwaySettings) =>
|
|
100
|
+
json<AwaySettings>(
|
|
101
|
+
await fetch("/api/away/settings", {
|
|
102
|
+
method: "PUT",
|
|
103
|
+
headers: { "Content-Type": "application/json" },
|
|
104
|
+
body: JSON.stringify(settings),
|
|
105
|
+
})
|
|
106
|
+
),
|
|
107
|
+
onSuccess: (data) => queryClient.setQueryData(settingsKey, data),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real activity, for Away mode: when a session last had a user or agent
|
|
3
|
+
* turn. Not `updated_at` (status polls bump it) and not tmux's activity
|
|
4
|
+
* clock where a better one exists, and never a turn Away mode itself
|
|
5
|
+
* started (an away line or digest an orchestrator was sent). The window
|
|
6
|
+
* past which a session counts as left alone is app-wide, in Settings →
|
|
7
|
+
* Workspaces.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "fs";
|
|
11
|
+
import { db, type Session } from "../db";
|
|
12
|
+
import type { ChatOrigin } from "../chat/events";
|
|
13
|
+
import { chatState } from "../chat/runner";
|
|
14
|
+
import { runningWorkers } from "../chat/worker/client";
|
|
15
|
+
import { statusDetector } from "../status-detector";
|
|
16
|
+
import { expandHome } from "../tasks/session";
|
|
17
|
+
import { isClaudeSessionId, transcriptPath } from "../tasks/transcript";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_WINDOW_MINUTES,
|
|
21
|
+
MAX_WINDOW_MINUTES,
|
|
22
|
+
MIN_WINDOW_MINUTES,
|
|
23
|
+
} from "./window-limits";
|
|
24
|
+
|
|
25
|
+
export const WINDOW_KEY = "away.window_minutes";
|
|
26
|
+
|
|
27
|
+
export function awayWindowMinutes(): number {
|
|
28
|
+
const row = db
|
|
29
|
+
.prepare(`SELECT value FROM settings WHERE key = ?`)
|
|
30
|
+
.get(WINDOW_KEY) as { value: string } | undefined;
|
|
31
|
+
const n = row ? Number(row.value) : DEFAULT_WINDOW_MINUTES;
|
|
32
|
+
return Number.isInteger(n) &&
|
|
33
|
+
n >= MIN_WINDOW_MINUTES &&
|
|
34
|
+
n <= MAX_WINDOW_MINUTES
|
|
35
|
+
? n
|
|
36
|
+
: DEFAULT_WINDOW_MINUTES;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const awayWindowMs = () => awayWindowMinutes() * 60_000;
|
|
40
|
+
|
|
41
|
+
export function setAwayWindowMinutes(value: unknown): number {
|
|
42
|
+
if (
|
|
43
|
+
typeof value !== "number" ||
|
|
44
|
+
!Number.isInteger(value) ||
|
|
45
|
+
value < MIN_WINDOW_MINUTES ||
|
|
46
|
+
value > MAX_WINDOW_MINUTES
|
|
47
|
+
)
|
|
48
|
+
throw new Error(
|
|
49
|
+
`The window is a whole number of minutes from ${MIN_WINDOW_MINUTES} to ${MAX_WINDOW_MINUTES}`
|
|
50
|
+
);
|
|
51
|
+
db.prepare(
|
|
52
|
+
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
53
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
54
|
+
).run(WINDOW_KEY, String(value));
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Kinds a turn writes; notes, errors, compactions and MCP listings aren't
|
|
59
|
+
// anyone working.
|
|
60
|
+
const TURN_KINDS = new Set([
|
|
61
|
+
"user",
|
|
62
|
+
"assistant",
|
|
63
|
+
"reasoning",
|
|
64
|
+
"tool",
|
|
65
|
+
"todos",
|
|
66
|
+
"plan",
|
|
67
|
+
"artifact",
|
|
68
|
+
"turn_end",
|
|
69
|
+
]);
|
|
70
|
+
const SCAN = 200;
|
|
71
|
+
|
|
72
|
+
interface Item {
|
|
73
|
+
kind?: string;
|
|
74
|
+
createdAt?: number;
|
|
75
|
+
origin?: ChatOrigin;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Newest first: a turn's items come before the message that started it.
|
|
79
|
+
// A turn Away mode started doesn't count; one an event, a peer or a
|
|
80
|
+
// schedule started counts from its first reply, not from the message.
|
|
81
|
+
export function lastChatTurn(items: Item[]): number {
|
|
82
|
+
let reply = 0;
|
|
83
|
+
for (const item of items) {
|
|
84
|
+
if (!item.kind || !TURN_KINDS.has(item.kind)) continue;
|
|
85
|
+
const at = item.createdAt ?? 0;
|
|
86
|
+
if (item.kind !== "user") {
|
|
87
|
+
reply = Math.max(reply, at);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const origin = item.origin;
|
|
91
|
+
if (origin?.away) {
|
|
92
|
+
reply = 0;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// Typed by the reader, or the reader's own decision.
|
|
96
|
+
if (!origin || origin.kind === "decision") return Math.max(reply, at);
|
|
97
|
+
if (reply) return reply;
|
|
98
|
+
}
|
|
99
|
+
return reply;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function chatTurn(sessionId: string): number {
|
|
103
|
+
const rows = db
|
|
104
|
+
.prepare(
|
|
105
|
+
`SELECT data FROM chat_items WHERE session_id = ? ORDER BY seq DESC LIMIT ?`
|
|
106
|
+
)
|
|
107
|
+
.all(sessionId, SCAN) as { data: string }[];
|
|
108
|
+
return lastChatTurn(
|
|
109
|
+
rows.map((r) => {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(r.data) as Item;
|
|
112
|
+
} catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A terminal's Claude conversation file is written on every turn and on
|
|
120
|
+
// nothing else; other agents fall back to tmux's output clock, which a
|
|
121
|
+
// status poll (capture-pane) doesn't move.
|
|
122
|
+
function terminalTurn(s: Session): number {
|
|
123
|
+
if (s.claude_session_id && isClaudeSessionId(s.claude_session_id)) {
|
|
124
|
+
try {
|
|
125
|
+
const cwd = expandHome(s.worktree_path || s.working_directory);
|
|
126
|
+
return fs.statSync(transcriptPath(cwd, s.claude_session_id)).mtimeMs;
|
|
127
|
+
} catch {
|
|
128
|
+
// No file here: fall through.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
statusDetector.getTimestamp(s.tmux_name, s.host_id || undefined) * 1000
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function lastTurnAt(s: Session): number {
|
|
137
|
+
return s.view === "chat" ? chatTurn(s.id) : terminalTurn(s);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Whether a chat's agent is up: a worker that went idle long enough exits,
|
|
141
|
+
// and a message would start it again and re-read the whole conversation.
|
|
142
|
+
// A terminal's is its tmux session (facts say "dead" when it's gone).
|
|
143
|
+
export function agentLive(s: Session): boolean {
|
|
144
|
+
if (s.view !== "chat")
|
|
145
|
+
return statusDetector.sessionExists(s.tmux_name, s.host_id || undefined);
|
|
146
|
+
return chatState(s.id) !== null || runningWorkers().includes(s.id);
|
|
147
|
+
}
|
package/lib/away/brief.ts
CHANGED
|
@@ -7,17 +7,25 @@ import {
|
|
|
7
7
|
activeAway,
|
|
8
8
|
clockTime,
|
|
9
9
|
fromSql,
|
|
10
|
+
reachedWorkspaces,
|
|
10
11
|
untilText,
|
|
11
12
|
type AwayRow,
|
|
12
13
|
} from "./state";
|
|
13
14
|
import { SWEEP_EVERY_MS } from "./sweep";
|
|
15
|
+
import { awayWindowMinutes } from "./activity";
|
|
14
16
|
|
|
15
17
|
export function awayOnLine(row: AwayRow, now = Date.now()): string {
|
|
16
|
-
return `Saad is away (Away mode, since ${clockTime(fromSql(row.started_at), now)}, ${untilText(row, now)}). Follow work through rather than only reacting to events: keep each task moving to merged and deployed, sign off what passes its gates, and pick up what stalled. Every ${SWEEP_EVERY_MS / 60000} minutes AgentOS sweeps: it
|
|
18
|
+
return `Saad is away (Away mode, since ${clockTime(fromSql(row.started_at), now)}, ${untilText(row, now)}). Follow work through rather than only reacting to events: keep each task moving to merged and deployed, sign off what passes its gates, and pick up what stalled. Every ${SWEEP_EVERY_MS / 60000} minutes AgentOS sweeps this workspace without messaging any session: it runs lost reviews again, checks the deploy after each merge, and sends you one digest of what's new (stalled tasks, idle ones with uncommitted work and no PR, Saad's own sessions idle with uncommitted work). You decide what needs work and act with your tools. Only sessions with real activity in the last ${awayWindowMinutes()} minutes are listed: leave the rest alone, since a message wakes an agent and re-reads its whole context. Interactive sessions (not tasks) are Saad's: ask a listed one at most once to wrap up, never drive them further. Hard lines still go to ask_saad; while he's away his asks reach his phone in one batch per sweep, so raise them as usual. \`aos notify\` is held for that batch too, so don't send one per item.`;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
// For the digest: the same line while Away is on
|
|
20
|
-
|
|
21
|
+
// For the digest: the same line while Away is on and has reached this
|
|
22
|
+
// workspace, else nothing.
|
|
23
|
+
export function awayDigestLine(
|
|
24
|
+
workspaceId: string,
|
|
25
|
+
now = Date.now()
|
|
26
|
+
): string | null {
|
|
21
27
|
const row = activeAway();
|
|
22
|
-
return row
|
|
28
|
+
return row && reachedWorkspaces(row.id).has(workspaceId)
|
|
29
|
+
? awayOnLine(row, now)
|
|
30
|
+
: null;
|
|
23
31
|
}
|
package/lib/away/report.ts
CHANGED
|
@@ -87,19 +87,22 @@ export function inFlightWhy(t: {
|
|
|
87
87
|
checks: CheckRow[];
|
|
88
88
|
failures: FailureRow[];
|
|
89
89
|
askKinds: string[];
|
|
90
|
-
|
|
90
|
+
// Times the sweep listed it for its orchestrator this period.
|
|
91
|
+
flagged: number;
|
|
91
92
|
}): string {
|
|
92
93
|
const escalated = t.failures.filter((f) => f.escalated_at);
|
|
93
94
|
if (escalated.length)
|
|
94
95
|
return `with you: the ${escalated.map((f) => f.gate).join(" and ")} gate failed twice`;
|
|
95
96
|
if (t.askKinds.length) return `waiting on your answer (${t.askKinds[0]})`;
|
|
96
|
-
const
|
|
97
|
-
|
|
97
|
+
const flagged = t.flagged
|
|
98
|
+
? `; flagged to the orchestrator ${t.flagged}x while you were away`
|
|
99
|
+
: "";
|
|
100
|
+
if (!t.prNumber) return `no PR yet${flagged}`;
|
|
98
101
|
const of = (kind: string) => t.checks.find((c) => c.kind === kind);
|
|
99
102
|
const review = of("review");
|
|
100
103
|
const ci = of("ci");
|
|
101
104
|
if (ci && ci.status !== "pass" && ci.status !== "running")
|
|
102
|
-
return `CI failing at ${sha7(ci.sha)}${
|
|
105
|
+
return `CI failing at ${sha7(ci.sha)}${flagged}`;
|
|
103
106
|
if (review?.status === "block")
|
|
104
107
|
return `review found blocking issues at ${sha7(review.sha)}`;
|
|
105
108
|
const failed = t.failures.filter((f) => f.count > 0);
|
|
@@ -113,6 +116,9 @@ export function inFlightWhy(t: {
|
|
|
113
116
|
return "PR open and reviewed, waiting on sign-off";
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
// What the sweep did to a task (./run), not just saw.
|
|
120
|
+
const TOUCHED = "'stalled', 'commit', 'review'";
|
|
121
|
+
|
|
116
122
|
interface TaskRow {
|
|
117
123
|
id: string;
|
|
118
124
|
name: string;
|
|
@@ -165,6 +171,8 @@ function merged(
|
|
|
165
171
|
});
|
|
166
172
|
}
|
|
167
173
|
|
|
174
|
+
// Running tasks the sweep touched: listed for the orchestrator, or their
|
|
175
|
+
// review run again. Ones it left alone aren't counted.
|
|
168
176
|
function inFlight(workspaceId: string, awayId: number): InFlightItem[] {
|
|
169
177
|
const tasks = db
|
|
170
178
|
.prepare(
|
|
@@ -173,16 +181,18 @@ function inFlight(workspaceId: string, awayId: number): InFlightItem[] {
|
|
|
173
181
|
FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
174
182
|
WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
|
|
175
183
|
AND s.task_status = 'running'
|
|
184
|
+
AND EXISTS (SELECT 1 FROM away_actions a WHERE a.away_id = ?
|
|
185
|
+
AND a.subject = s.id AND a.kind IN (${TOUCHED}))
|
|
176
186
|
ORDER BY s.created_at, s.id`
|
|
177
187
|
)
|
|
178
|
-
.all(workspaceId) as TaskRow[];
|
|
188
|
+
.all(workspaceId, awayId) as TaskRow[];
|
|
179
189
|
if (!tasks.length) return [];
|
|
180
190
|
const checks = checksBySession(workspaceId);
|
|
181
191
|
const failures = failuresBySession(workspaceId);
|
|
182
192
|
const asks = openAsks(workspaceId);
|
|
183
|
-
const
|
|
193
|
+
const flagged = db.prepare(
|
|
184
194
|
`SELECT COUNT(*) AS n FROM away_actions
|
|
185
|
-
WHERE away_id = ? AND subject = ? AND kind
|
|
195
|
+
WHERE away_id = ? AND subject = ? AND kind IN ('stalled', 'commit')`
|
|
186
196
|
);
|
|
187
197
|
return tasks.map((t) => ({
|
|
188
198
|
task: t.name,
|
|
@@ -197,7 +207,7 @@ function inFlight(workspaceId: string, awayId: number): InFlightItem[] {
|
|
|
197
207
|
askKinds: asks
|
|
198
208
|
.filter((a) => a.subject === `task:${t.id}`)
|
|
199
209
|
.map((a) => a.kind),
|
|
200
|
-
|
|
210
|
+
flagged: (flagged.get(awayId, t.id) as { n: number }).n,
|
|
201
211
|
}),
|
|
202
212
|
}));
|
|
203
213
|
}
|
|
@@ -261,12 +271,16 @@ function waiting(workspaceId: string): WaitingItem[] {
|
|
|
261
271
|
}));
|
|
262
272
|
}
|
|
263
273
|
|
|
274
|
+
// Only the workspaces Away mode reached (`reached`, whose orchestrator was
|
|
275
|
+
// active and told); the rest were left alone.
|
|
264
276
|
export function buildAwayReport(
|
|
265
277
|
away: AwayRow,
|
|
266
|
-
deployConfigured: (projectId: string) => boolean
|
|
278
|
+
deployConfigured: (projectId: string) => boolean,
|
|
279
|
+
reached: ReadonlySet<string>
|
|
267
280
|
): AwayReport {
|
|
268
281
|
const end = away.ended_at ?? away.started_at;
|
|
269
282
|
const workspaces = listWorkspaces()
|
|
283
|
+
.filter((w) => reached.has(w.id))
|
|
270
284
|
.map((w) => ({
|
|
271
285
|
id: w.id,
|
|
272
286
|
name: w.name,
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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(
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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<
|
|
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
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 = "
|
|
31
|
+
export type CandidateKind = "stalled" | "commit" | "review" | "wrapup";
|
|
28
32
|
|
|
29
33
|
export interface Candidate {
|
|
30
34
|
kind: CandidateKind;
|
|
31
35
|
sessionId: string;
|
|
32
|
-
//
|
|
36
|
+
// Listed (or run, for a review) once per Away period.
|
|
33
37
|
key: string;
|
|
34
|
-
//
|
|
35
|
-
|
|
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
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
119
|
+
eligible(f, v) &&
|
|
94
120
|
f.status === "idle" &&
|
|
95
121
|
!f.task?.pr &&
|
|
96
|
-
now - f
|
|
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
|
|
108
|
-
const
|
|
109
|
-
const quiet = v.now -
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
150
|
+
kind: "stalled",
|
|
134
151
|
sessionId: f.id,
|
|
135
152
|
key,
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
191
|
-
//
|
|
192
|
-
//
|
|
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
|
-
|
|
209
|
+
flags: Candidate[];
|
|
210
|
+
reviews: Candidate[];
|
|
195
211
|
firstSeen: string[];
|
|
196
212
|
} {
|
|
197
|
-
const
|
|
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))
|
|
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 {
|
|
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
|
+
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -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.
|
package/lib/models/roles.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ChatOrigin, ChatState } from "../chat/events";
|
|
|
2
2
|
import type { CompactFirst } from "../chat/stale";
|
|
3
3
|
import {
|
|
4
4
|
isAnswer,
|
|
5
|
+
isAwayEvent,
|
|
5
6
|
deliveriesSince,
|
|
6
7
|
lastDeliveredAt,
|
|
7
8
|
logDeliveries,
|
|
@@ -91,6 +92,7 @@ export async function deliverEvents(opts: {
|
|
|
91
92
|
kind: "event",
|
|
92
93
|
label: "AgentOS",
|
|
93
94
|
...(decided.length ? { decided } : {}),
|
|
95
|
+
...(pending.every((e) => isAwayEvent(e)) ? { away: true as const } : {}),
|
|
94
96
|
};
|
|
95
97
|
markDelivered(ids, now);
|
|
96
98
|
const logged = logDeliveries(
|
|
@@ -324,7 +324,7 @@ export function buildDigest(
|
|
|
324
324
|
): string {
|
|
325
325
|
const checks = checksBySession(workspaceId);
|
|
326
326
|
const failures = failuresBySession(workspaceId);
|
|
327
|
-
const away = awayDigestLine();
|
|
327
|
+
const away = awayDigestLine(workspaceId);
|
|
328
328
|
const head = `## Where things stand (read from AgentOS's database as you started)${away ? `\n\n${away}` : ""}`;
|
|
329
329
|
const body = renderDigest(
|
|
330
330
|
[
|
|
@@ -40,6 +40,9 @@ const ONCE = "once:";
|
|
|
40
40
|
export const answerKey = (askId: number) => `ask:${askId}`;
|
|
41
41
|
export const isAnswer = (e: Pick<EventRow, "key">) =>
|
|
42
42
|
e.key.startsWith(`${ONCE}ask:`);
|
|
43
|
+
// Away mode's own one-off lines (lib/away/run) all start "away".
|
|
44
|
+
export const isAwayEvent = (e: Pick<EventRow, "key">) =>
|
|
45
|
+
e.key.startsWith(`${ONCE}away`);
|
|
43
46
|
|
|
44
47
|
const iso = (ms: number) => new Date(ms).toISOString();
|
|
45
48
|
|