@saadnvd1/agent-os 0.3.460 → 0.3.462
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 +51 -3
- package/app/api/away/route.ts +56 -0
- package/app/api/away/seen/route.ts +12 -0
- package/app/api/health/route.ts +9 -0
- package/app/page.tsx +2 -0
- package/components/Away/AwayReport.tsx +16 -0
- package/components/Away/AwayReportDialog.tsx +193 -0
- package/components/Away/AwayToggle.tsx +91 -0
- package/components/Away/time.test.ts +20 -0
- package/components/Away/time.ts +32 -0
- package/components/Away/useAwayCommands.ts +43 -0
- package/components/Chat/Messages.tsx +1 -0
- package/components/CommandPalette/useAppCommands.ts +2 -0
- package/components/Pane/MobileTabBar.tsx +14 -1
- package/components/SessionList/SidebarHeader.tsx +2 -0
- package/data/away/index.ts +73 -0
- package/data/push/topics.ts +4 -0
- package/lib/away/brief.ts +23 -0
- package/lib/away/deploy.ts +173 -0
- package/lib/away/report.ts +398 -0
- package/lib/away/run.ts +445 -0
- package/lib/away/state.ts +201 -0
- package/lib/away/sweep.ts +216 -0
- package/lib/away/view.ts +51 -0
- package/lib/chat/events.ts +1 -1
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +69 -0
- package/lib/db/types.ts +4 -0
- package/lib/notify/index.ts +7 -0
- package/lib/orchestrator/digest.ts +11 -8
- package/lib/orchestrator/notes.ts +8 -1
- package/lib/orchestrator/watcher.ts +6 -0
- package/lib/project-config/agentos.schema.json +24 -0
- package/lib/project-config/schema.ts +17 -0
- package/lib/worktree-hooks.ts +9 -0
- package/package.json +1 -1
- package/scripts/hooks/staged-secrets.test.ts +92 -0
- package/scripts/hooks/staged-secrets.ts +64 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The away sweep's choices, made by code from what the server already knows
|
|
3
|
+
* (session facts, git status, stored review verdicts), never by a model
|
|
4
|
+
* reading the workspace. Pure: ./run gathers the inputs and acts.
|
|
5
|
+
*
|
|
6
|
+
* - A task with no activity for STALL_MS and nothing to wait on is nudged,
|
|
7
|
+
* at most MAX_NUDGES times a period, and again only after it moved.
|
|
8
|
+
* - A task idle with uncommitted work and no PR is asked to commit and open
|
|
9
|
+
* one, or say why not.
|
|
10
|
+
* - A PR whose CI settled green with no review of its head, or one that was
|
|
11
|
+
* lost (errored, or cut off and stale), has its review run again, once
|
|
12
|
+
* the sweep has seen it that way twice, so the orchestrator had its turn.
|
|
13
|
+
* - An interactive session is Saad's: one with uncommitted work is asked to
|
|
14
|
+
* wrap up, once a period, and nothing more.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isFinished } from "../tasks/state";
|
|
18
|
+
import type { SessionFacts } from "../orchestrator/facts";
|
|
19
|
+
|
|
20
|
+
export const SWEEP_EVERY_MS = 10 * 60 * 1000;
|
|
21
|
+
export const STALL_MS = 30 * 60 * 1000;
|
|
22
|
+
export const IDLE_DIRTY_MS = 15 * 60 * 1000;
|
|
23
|
+
export const MAX_NUDGES = 2;
|
|
24
|
+
// A review "running" this long was cut off (as clearStaleRunning has it).
|
|
25
|
+
export const STALE_REVIEW_MS = 30 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export type CandidateKind = "nudge" | "commit" | "review" | "wrapup";
|
|
28
|
+
|
|
29
|
+
export interface Candidate {
|
|
30
|
+
kind: CandidateKind;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
// Acted on once per Away period.
|
|
33
|
+
key: string;
|
|
34
|
+
// What the session is told (none for a review).
|
|
35
|
+
message?: string;
|
|
36
|
+
// One line for the decision log.
|
|
37
|
+
log: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ReviewSeen {
|
|
41
|
+
status: "running" | "pass" | "block" | "error";
|
|
42
|
+
at: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SweepView {
|
|
46
|
+
now: number;
|
|
47
|
+
facts: SessionFacts[];
|
|
48
|
+
// Sessions with uncommitted changes in their worktree.
|
|
49
|
+
dirty: ReadonlySet<string>;
|
|
50
|
+
// The stored review verdict of a session's commit, if any.
|
|
51
|
+
reviewOf: (sessionId: string, sha: string) => ReviewSeen | null;
|
|
52
|
+
// Tasks that are Saad's now: escalated, or an open ask on them.
|
|
53
|
+
held: ReadonlySet<string>;
|
|
54
|
+
// Keys acted on (or seen, for reviews) this period.
|
|
55
|
+
acted: ReadonlySet<string>;
|
|
56
|
+
// Nudges sent to each session this period.
|
|
57
|
+
nudges: ReadonlyMap<string, number>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const minutes = (ms: number) => Math.floor(ms / 60000);
|
|
61
|
+
|
|
62
|
+
const BLOCKED_HINT =
|
|
63
|
+
"If only Saad can unblock it, end with a line starting BLOCKED: that says what you need.";
|
|
64
|
+
|
|
65
|
+
// Live work with an agent to answer: not finished, not a linked machine's
|
|
66
|
+
// copy, not setting up, not waiting on someone already.
|
|
67
|
+
function workable(f: SessionFacts): boolean {
|
|
68
|
+
if (f.status === "dead" || f.status === "waiting" || f.needsInput)
|
|
69
|
+
return false;
|
|
70
|
+
if (f.activity === "setting up") return false;
|
|
71
|
+
if (
|
|
72
|
+
f.task &&
|
|
73
|
+
(isFinished(f.task.state) ||
|
|
74
|
+
f.task.state === "moving" ||
|
|
75
|
+
f.task.state === "exited" ||
|
|
76
|
+
f.task.blocked !== null)
|
|
77
|
+
)
|
|
78
|
+
return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Sessions worth a `git status`: quiet long enough that uncommitted work
|
|
83
|
+
// would matter, and not already asked about it this period. Keeps the
|
|
84
|
+
// sweep from reading every worktree.
|
|
85
|
+
export function needsGitCheck(
|
|
86
|
+
facts: SessionFacts[],
|
|
87
|
+
now: number,
|
|
88
|
+
acted: ReadonlySet<string>
|
|
89
|
+
): string[] {
|
|
90
|
+
return facts
|
|
91
|
+
.filter(
|
|
92
|
+
(f) =>
|
|
93
|
+
workable(f) &&
|
|
94
|
+
f.status === "idle" &&
|
|
95
|
+
!f.task?.pr &&
|
|
96
|
+
now - f.lastActive >= IDLE_DIRTY_MS &&
|
|
97
|
+
!acted.has(f.task ? commitKey(f) : wrapupKey(f))
|
|
98
|
+
)
|
|
99
|
+
.map((f) => f.id);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const commitKey = (f: SessionFacts) => `commit:${f.id}:${f.lastActive}`;
|
|
103
|
+
const wrapupKey = (f: SessionFacts) => `wrapup:${f.id}`;
|
|
104
|
+
export const seenKey = (id: string, sha: string) => `review-seen:${id}:${sha}`;
|
|
105
|
+
|
|
106
|
+
function taskCandidate(f: SessionFacts, v: SweepView): Candidate | null {
|
|
107
|
+
const task = f.task!;
|
|
108
|
+
const pr = task.pr;
|
|
109
|
+
const quiet = v.now - f.lastActive;
|
|
110
|
+
const who = `task ${f.name}`;
|
|
111
|
+
|
|
112
|
+
if (!pr && f.status === "idle" && v.dirty.has(f.id)) {
|
|
113
|
+
const key = commitKey(f);
|
|
114
|
+
if (v.acted.has(key)) return null;
|
|
115
|
+
return {
|
|
116
|
+
kind: "commit",
|
|
117
|
+
sessionId: f.id,
|
|
118
|
+
key,
|
|
119
|
+
message: `Saad is away. You've been idle ${minutes(quiet)} minutes with uncommitted work and no pull request. Commit it and open a pull request, as your task says. If it shouldn't be one, end with a line starting BLOCKED: that says why not.`,
|
|
120
|
+
log: `${who}: idle ${minutes(quiet)}m with uncommitted work and no PR; asked it to commit and open one, or say why not.`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const ciFailing = pr?.state === "OPEN" && pr.checks === "fail";
|
|
125
|
+
if ((!pr || ciFailing) && quiet >= STALL_MS) {
|
|
126
|
+
if ((v.nudges.get(f.id) ?? 0) >= MAX_NUDGES) return null;
|
|
127
|
+
const key = `nudge:${f.id}:${f.lastActive}`;
|
|
128
|
+
if (v.acted.has(key)) return null;
|
|
129
|
+
const what = ciFailing
|
|
130
|
+
? `CI is failing on PR #${pr!.number}. Fix it and push.`
|
|
131
|
+
: "Carry the task on to a pull request.";
|
|
132
|
+
return {
|
|
133
|
+
kind: "nudge",
|
|
134
|
+
sessionId: f.id,
|
|
135
|
+
key,
|
|
136
|
+
message: `Saad is away, and AgentOS hasn't seen activity from you in ${minutes(quiet)} minutes. ${what} ${BLOCKED_HINT}`,
|
|
137
|
+
log: `${who}: no activity for ${minutes(quiet)}m${ciFailing ? ` with CI failing on PR #${pr!.number}` : ", no PR"}; nudged it.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function reviewCandidate(f: SessionFacts, v: SweepView): Candidate | null {
|
|
144
|
+
const pr = f.task?.pr;
|
|
145
|
+
if (
|
|
146
|
+
!f.task ||
|
|
147
|
+
isFinished(f.task.state) ||
|
|
148
|
+
pr?.state !== "OPEN" ||
|
|
149
|
+
!pr.head ||
|
|
150
|
+
pr.checks !== "pass" ||
|
|
151
|
+
(f.task.ciSettleIn ?? 0) > 0 ||
|
|
152
|
+
v.held.has(f.id)
|
|
153
|
+
)
|
|
154
|
+
return null;
|
|
155
|
+
const seen = v.reviewOf(f.id, pr.head);
|
|
156
|
+
const lost =
|
|
157
|
+
!seen ||
|
|
158
|
+
seen.status === "error" ||
|
|
159
|
+
(seen.status === "running" && v.now - seen.at >= STALE_REVIEW_MS);
|
|
160
|
+
if (!lost) return null;
|
|
161
|
+
const key = `review:${f.id}:${pr.head}`;
|
|
162
|
+
if (v.acted.has(key)) return null;
|
|
163
|
+
const why = !seen
|
|
164
|
+
? "never started"
|
|
165
|
+
: seen.status === "error"
|
|
166
|
+
? "lost (it errored)"
|
|
167
|
+
: "lost (cut off)";
|
|
168
|
+
return {
|
|
169
|
+
kind: "review",
|
|
170
|
+
sessionId: f.id,
|
|
171
|
+
key,
|
|
172
|
+
log: `task ${f.name}: the review of PR #${pr.number} at ${pr.head.slice(0, 7)} was ${why}; ran it again.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function wrapupCandidate(f: SessionFacts, v: SweepView): Candidate | null {
|
|
177
|
+
if (f.status !== "idle" || !v.dirty.has(f.id)) return null;
|
|
178
|
+
const key = wrapupKey(f);
|
|
179
|
+
if (v.acted.has(key)) return null;
|
|
180
|
+
return {
|
|
181
|
+
kind: "wrapup",
|
|
182
|
+
sessionId: f.id,
|
|
183
|
+
key,
|
|
184
|
+
message:
|
|
185
|
+
"Saad is away. This session has uncommitted work. Wrap up: on a feature branch, commit what you have with a clear message; on the default branch, leave it uncommitted. Then stop and don't start anything new; Saad picks this up when he's back.",
|
|
186
|
+
log: `${f.name}: Saad's own session, idle with uncommitted work; asked it to wrap up.`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// What to act on now. A review candidate seen for the first time is
|
|
191
|
+
// returned with kind "review" only once its seen-key is in `acted`; the
|
|
192
|
+
// caller records the seen-key otherwise (see `firstSeen`).
|
|
193
|
+
export function pickCandidates(v: SweepView): {
|
|
194
|
+
act: Candidate[];
|
|
195
|
+
firstSeen: string[];
|
|
196
|
+
} {
|
|
197
|
+
const act: Candidate[] = [];
|
|
198
|
+
const firstSeen: string[] = [];
|
|
199
|
+
for (const f of v.facts) {
|
|
200
|
+
if (f.task) {
|
|
201
|
+
const review = reviewCandidate(f, v);
|
|
202
|
+
if (review) {
|
|
203
|
+
const seen = seenKey(f.id, f.task.pr!.head!);
|
|
204
|
+
if (v.acted.has(seen)) act.push(review);
|
|
205
|
+
else firstSeen.push(seen);
|
|
206
|
+
}
|
|
207
|
+
if (!workable(f) || v.held.has(f.id)) continue;
|
|
208
|
+
const c = taskCandidate(f, v);
|
|
209
|
+
if (c) act.push(c);
|
|
210
|
+
} else if (workable(f)) {
|
|
211
|
+
const c = wrapupCandidate(f, v);
|
|
212
|
+
if (c) act.push(c);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { act, firstSeen };
|
|
216
|
+
}
|
package/lib/away/view.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// What the browser gets: Away's state now, and the newest report Saad
|
|
2
|
+
// hasn't dismissed. Times are ISO.
|
|
3
|
+
|
|
4
|
+
import type { AwayReport } from "./report";
|
|
5
|
+
import {
|
|
6
|
+
activeAway,
|
|
7
|
+
fromSql,
|
|
8
|
+
isExpired,
|
|
9
|
+
unseenReport,
|
|
10
|
+
type AwayRow,
|
|
11
|
+
} from "./state";
|
|
12
|
+
import { turnAwayOff } from "./run";
|
|
13
|
+
|
|
14
|
+
export interface AwayView {
|
|
15
|
+
away: {
|
|
16
|
+
id: number;
|
|
17
|
+
setBy: string;
|
|
18
|
+
startedAt: string;
|
|
19
|
+
expiresAt: string | null;
|
|
20
|
+
} | null;
|
|
21
|
+
report: AwayReport | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const iso = (t: string) => new Date(fromSql(t)).toISOString();
|
|
25
|
+
|
|
26
|
+
function reportOf(row: AwayRow | null): AwayReport | null {
|
|
27
|
+
if (!row?.report) return null;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(row.report) as AwayReport;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function awayView(now = Date.now()): AwayView {
|
|
36
|
+
let away = activeAway();
|
|
37
|
+
// The watcher ends it on time; this covers a server running without one.
|
|
38
|
+
if (away && isExpired(away, now)) {
|
|
39
|
+
turnAwayOff("expired", now);
|
|
40
|
+
away = null;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
away: away && {
|
|
44
|
+
id: away.id,
|
|
45
|
+
setBy: away.set_by,
|
|
46
|
+
startedAt: iso(away.started_at),
|
|
47
|
+
expiresAt: away.expires_at ? iso(away.expires_at) : null,
|
|
48
|
+
},
|
|
49
|
+
report: reportOf(unseenReport()),
|
|
50
|
+
};
|
|
51
|
+
}
|
package/lib/chat/events.ts
CHANGED
package/lib/db/changes.ts
CHANGED
|
@@ -27,11 +27,14 @@ export const TABLES_WATCHED_50 = ["task_queue"] as const;
|
|
|
27
27
|
|
|
28
28
|
export const TABLES_WATCHED_56 = ["orchestrator_rules"] as const;
|
|
29
29
|
|
|
30
|
+
export const TABLES_WATCHED_58 = ["away_periods"] as const;
|
|
31
|
+
|
|
30
32
|
// Every table any migration watches, for the browser's topic map to cover.
|
|
31
33
|
export const WATCHED_TABLES: readonly string[] = [
|
|
32
34
|
...TABLES_WATCHED_45,
|
|
33
35
|
...TABLES_WATCHED_50,
|
|
34
36
|
...TABLES_WATCHED_56,
|
|
37
|
+
...TABLES_WATCHED_58,
|
|
35
38
|
];
|
|
36
39
|
|
|
37
40
|
export function installChangeTriggers(
|
package/lib/db/migrations.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
TABLES_WATCHED_45,
|
|
4
4
|
TABLES_WATCHED_50,
|
|
5
5
|
TABLES_WATCHED_56,
|
|
6
|
+
TABLES_WATCHED_58,
|
|
6
7
|
} from "./changes";
|
|
7
8
|
import type Database from "better-sqlite3";
|
|
8
9
|
|
|
@@ -1258,6 +1259,74 @@ const migrations: Migration[] = [
|
|
|
1258
1259
|
);
|
|
1259
1260
|
},
|
|
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
|
+
},
|
|
1261
1330
|
];
|
|
1262
1331
|
|
|
1263
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;
|
package/lib/notify/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { db, type Session } from "../db";
|
|
9
9
|
import { Limiter, type Outcome } from "./limiter";
|
|
10
|
+
import { holdForBatch, msToNextSweep } from "../away/state";
|
|
10
11
|
import { selectNotifier, type TelegramConfig } from "./notifiers";
|
|
11
12
|
|
|
12
13
|
export type { Outcome } from "./limiter";
|
|
@@ -135,6 +136,12 @@ export async function notifyFrom(
|
|
|
135
136
|
if (from && !session) throw new Error("Unknown sender session");
|
|
136
137
|
const body = text.trim();
|
|
137
138
|
if (!body) throw new Error("Nothing to send");
|
|
139
|
+
if (
|
|
140
|
+
session?.role === "orchestrator" &&
|
|
141
|
+
session.workspace_id &&
|
|
142
|
+
holdForBatch(session.workspace_id, session.id, body)
|
|
143
|
+
)
|
|
144
|
+
return { state: "held", inMs: msToNextSweep() };
|
|
138
145
|
return sendPhone(
|
|
139
146
|
session ? `session:${session.id}` : "cli",
|
|
140
147
|
session ? `${session.name}: ${body}` : body
|
|
@@ -12,6 +12,7 @@ import { openAsks } from "./asks";
|
|
|
12
12
|
import { listRules } from "./rules";
|
|
13
13
|
import { shortId } from "./describe";
|
|
14
14
|
import { untrusted } from "./untrusted";
|
|
15
|
+
import { awayDigestLine } from "../away/brief";
|
|
15
16
|
|
|
16
17
|
// About four characters a token: close enough to cap a prompt.
|
|
17
18
|
export const CHARS_PER_TOKEN = 4;
|
|
@@ -23,7 +24,7 @@ const EXTERNAL_DAYS = 2;
|
|
|
23
24
|
|
|
24
25
|
// One line, cut to length. A cut that leaves a fence around another
|
|
25
26
|
// session's text open closes it, so what follows isn't read as inside it.
|
|
26
|
-
const clip = (text: string, cap = LINE_CAP) => {
|
|
27
|
+
export const clip = (text: string, cap = LINE_CAP) => {
|
|
27
28
|
const one = text.replace(/\s+/g, " ").trim();
|
|
28
29
|
if (one.length <= cap) return one;
|
|
29
30
|
const cut = `${one.slice(0, cap - 3)}...`;
|
|
@@ -53,21 +54,21 @@ interface TaskRow {
|
|
|
53
54
|
armed_at: string | null;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
interface CheckRow {
|
|
57
|
+
export interface CheckRow {
|
|
57
58
|
session_id: string;
|
|
58
59
|
sha: string;
|
|
59
60
|
kind: string;
|
|
60
61
|
status: string;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
interface FailureRow {
|
|
64
|
+
export interface FailureRow {
|
|
64
65
|
session_id: string;
|
|
65
66
|
gate: string;
|
|
66
67
|
count: number;
|
|
67
68
|
escalated_at: string | null;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
function checksBySession(workspaceId: string): Map<string, CheckRow[]> {
|
|
71
|
+
export function checksBySession(workspaceId: string): Map<string, CheckRow[]> {
|
|
71
72
|
// The newest verdict of each kind per session or PR.
|
|
72
73
|
const rows = db
|
|
73
74
|
.prepare(
|
|
@@ -85,7 +86,9 @@ function checksBySession(workspaceId: string): Map<string, CheckRow[]> {
|
|
|
85
86
|
return by;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
function failuresBySession(
|
|
89
|
+
export function failuresBySession(
|
|
90
|
+
workspaceId: string
|
|
91
|
+
): Map<string, FailureRow[]> {
|
|
89
92
|
const rows = db
|
|
90
93
|
.prepare(
|
|
91
94
|
`SELECT session_id, gate, count, escalated_at FROM orchestrator_gate_failures
|
|
@@ -98,7 +101,7 @@ function failuresBySession(workspaceId: string): Map<string, FailureRow[]> {
|
|
|
98
101
|
return by;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
function gateState(
|
|
104
|
+
export function gateState(
|
|
102
105
|
checks: CheckRow[] = [],
|
|
103
106
|
failures: FailureRow[] = []
|
|
104
107
|
): string {
|
|
@@ -321,8 +324,8 @@ export function buildDigest(
|
|
|
321
324
|
): string {
|
|
322
325
|
const checks = checksBySession(workspaceId);
|
|
323
326
|
const failures = failuresBySession(workspaceId);
|
|
324
|
-
const
|
|
325
|
-
|
|
327
|
+
const away = awayDigestLine();
|
|
328
|
+
const head = `## Where things stand (read from AgentOS's database as you started)${away ? `\n\n${away}` : ""}`;
|
|
326
329
|
const body = renderDigest(
|
|
327
330
|
[
|
|
328
331
|
rulesSection(workspaceId),
|
|
@@ -10,7 +10,14 @@ import { saveItem } from "../chat/store";
|
|
|
10
10
|
import type { ChatItem } from "../chat/events";
|
|
11
11
|
import { getOrchestrator } from "./home";
|
|
12
12
|
|
|
13
|
-
export type NoteKind =
|
|
13
|
+
export type NoteKind =
|
|
14
|
+
| "note"
|
|
15
|
+
| "brake"
|
|
16
|
+
| "escalation"
|
|
17
|
+
| "ask"
|
|
18
|
+
| "pause"
|
|
19
|
+
// What Away mode's sweep did (lib/away).
|
|
20
|
+
| "away";
|
|
14
21
|
|
|
15
22
|
export interface NoteRow {
|
|
16
23
|
id: number;
|
|
@@ -18,6 +18,7 @@ import { brakesOn, liftBrake } from "./brakes";
|
|
|
18
18
|
import { anyArmed, mergeArmed } from "./armed";
|
|
19
19
|
import { rebaseConflicting } from "./rebase";
|
|
20
20
|
import { wakeTasks } from "./pr-wake";
|
|
21
|
+
import { awayTick } from "../away/run";
|
|
21
22
|
|
|
22
23
|
// Asks that stopped needing Saad: their task or PR finished some other
|
|
23
24
|
// way, or brakes lifted with nothing trying to start.
|
|
@@ -101,6 +102,11 @@ async function tick(): Promise<void> {
|
|
|
101
102
|
if (busy) return;
|
|
102
103
|
busy = true;
|
|
103
104
|
try {
|
|
105
|
+
try {
|
|
106
|
+
awayTick();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error("Away mode:", error);
|
|
109
|
+
}
|
|
104
110
|
for (const orch of listOrchestrators()) {
|
|
105
111
|
const workspaceId = orch.workspace_id;
|
|
106
112
|
if (!workspaceId || !getWorkspace(workspaceId)) continue;
|
|
@@ -73,6 +73,30 @@
|
|
|
73
73
|
},
|
|
74
74
|
"additionalProperties": false
|
|
75
75
|
},
|
|
76
|
+
"deploy": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": {
|
|
79
|
+
"deployed": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"minLength": 1
|
|
82
|
+
},
|
|
83
|
+
"failed": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"minLength": 1
|
|
86
|
+
},
|
|
87
|
+
"health": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"minLength": 1
|
|
90
|
+
},
|
|
91
|
+
"within_minutes": {
|
|
92
|
+
"type": "integer",
|
|
93
|
+
"minimum": 1,
|
|
94
|
+
"maximum": 1440
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"required": ["deployed"],
|
|
98
|
+
"additionalProperties": false
|
|
99
|
+
},
|
|
76
100
|
"test": {
|
|
77
101
|
"type": "string",
|
|
78
102
|
"minLength": 1
|
|
@@ -38,6 +38,22 @@ const ready = z.strictObject({
|
|
|
38
38
|
contains: z.string().optional(),
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
// How Away mode checks a merge went live (lib/away/deploy.ts): a file the
|
|
42
|
+
// deploy writes the deployed commit to, one it writes a commit whose deploy
|
|
43
|
+
// failed to, and a URL that answers 2xx when the server is healthy (a path
|
|
44
|
+
// alone is this AgentOS server's own).
|
|
45
|
+
const deploy = z.strictObject({
|
|
46
|
+
deployed: z.string().min(1),
|
|
47
|
+
failed: z.string().min(1).optional(),
|
|
48
|
+
health: z.string().min(1).optional(),
|
|
49
|
+
within_minutes: z
|
|
50
|
+
.number()
|
|
51
|
+
.int()
|
|
52
|
+
.min(1)
|
|
53
|
+
.max(24 * 60)
|
|
54
|
+
.optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
41
57
|
const browse = z.strictObject({
|
|
42
58
|
login: z.string().min(1).optional(),
|
|
43
59
|
map: projectPath.optional(),
|
|
@@ -89,6 +105,7 @@ const shape = {
|
|
|
89
105
|
clone: z.array(projectPath).optional(),
|
|
90
106
|
dev: z.string().min(1).optional(),
|
|
91
107
|
ready: ready.optional(),
|
|
108
|
+
deploy: deploy.optional(),
|
|
92
109
|
test: z.string().min(1).optional(),
|
|
93
110
|
notes: notes.optional(),
|
|
94
111
|
browse: browse.optional(),
|
package/lib/worktree-hooks.ts
CHANGED
|
@@ -85,10 +85,19 @@ async function ownHooks(worktree: string): Promise<string> {
|
|
|
85
85
|
return sh(path.join(common, "hooks"));
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// Husky writes `.husky/_` on `npm install`, and it's gitignored, so a new
|
|
89
|
+
// worktree has none and its hooks would silently not run: run the hook
|
|
90
|
+
// script from `.husky/` the way husky's own runner does.
|
|
88
91
|
const delegate = (own: string, hook: string) =>
|
|
89
92
|
`root="$(git rev-parse --show-toplevel)"
|
|
90
93
|
own=${own}/${hook}
|
|
91
94
|
[ -x "$own" ] && exec "$own" "$@"
|
|
95
|
+
case "$own" in
|
|
96
|
+
*/.husky/_/${hook})
|
|
97
|
+
script="\${own%/_/${hook}}/${hook}"
|
|
98
|
+
[ -f "$script" ] && [ "\${HUSKY-}" != 0 ] && PATH="node_modules/.bin:$PATH" exec sh -e "$script" "$@"
|
|
99
|
+
;;
|
|
100
|
+
esac
|
|
92
101
|
exit 0
|
|
93
102
|
`;
|
|
94
103
|
|