@saadnvd1/agent-os 0.3.616 → 0.3.624

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +1 -0
  2. package/app/api/health/anomalies/route.ts +45 -0
  3. package/app/api/health/config/route.ts +31 -0
  4. package/app/api/health/notes/route.ts +33 -0
  5. package/components/Chat/ChatPanel.tsx +2 -1
  6. package/components/Health/HealthDot.tsx +35 -0
  7. package/components/Health/HealthPanel.tsx +412 -0
  8. package/components/Pane/DesktopTabBar.tsx +4 -0
  9. package/components/Pane/MobileTabBar.tsx +4 -0
  10. package/components/Pane/MobileViewPicker.test.ts +8 -0
  11. package/components/Pane/MobileViewPicker.tsx +4 -1
  12. package/components/Pane/PaneViewToggle.test.ts +82 -0
  13. package/components/Pane/PaneViewToggle.tsx +16 -8
  14. package/components/Pane/git-visibility.ts +9 -0
  15. package/components/Pane/index.tsx +18 -2
  16. package/components/SessionList/SidebarHeader.tsx +2 -0
  17. package/components/Settings/SettingsDialog.tsx +9 -0
  18. package/components/views/DesktopAppActions.tsx +23 -17
  19. package/components/views/DesktopView.tsx +1 -1
  20. package/data/git/queries.ts +16 -3
  21. package/data/health/index.ts +111 -0
  22. package/lib/chat/pending.ts +13 -1
  23. package/lib/db/migrations/20261015T1500_health_watch.ts +68 -0
  24. package/lib/health/boots.ts +79 -0
  25. package/lib/health/ci.ts +90 -0
  26. package/lib/health/config.ts +79 -0
  27. package/lib/health/detectors.ts +227 -0
  28. package/lib/health/errors.ts +90 -0
  29. package/lib/health/evidence.ts +172 -0
  30. package/lib/health/facts.ts +159 -0
  31. package/lib/health/fixtures.ts +30 -0
  32. package/lib/health/notes.ts +76 -0
  33. package/lib/health/pieces.ts +120 -0
  34. package/lib/health/route.ts +113 -0
  35. package/lib/health/store.ts +212 -0
  36. package/lib/health/tick.ts +149 -0
  37. package/lib/health/types.ts +148 -0
  38. package/lib/health/view.ts +120 -0
  39. package/lib/health/watchers.ts +176 -0
  40. package/lib/learning-cards/apply.ts +54 -8
  41. package/lib/load/alert.ts +4 -8
  42. package/lib/models/plan-step.ts +18 -3
  43. package/lib/models/roles.ts +6 -0
  44. package/lib/orchestrator/deliver.ts +1 -0
  45. package/lib/orchestrator/events.ts +13 -4
  46. package/lib/orchestrator/watcher.ts +12 -0
  47. package/lib/plans/release.ts +7 -0
  48. package/lib/settings-url.ts +1 -0
  49. package/lib/tasks/refresh-base.ts +78 -0
  50. package/package.json +1 -1
  51. package/scripts/autodeploy +2 -2
  52. package/scripts/autodeploy.test.ts +2 -2
  53. package/server.ts +11 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * One snapshot of what the detectors need, read cheaply: database rows, the
3
+ * load monitor's last sample, a file's mtime. Nothing here calls gh, a
4
+ * model or a process listing, because it runs every minute.
5
+ */
6
+
7
+ import { db } from "../db";
8
+ import { itemsOfKind } from "../chat/store";
9
+ import { taskOutputTail } from "../chat/task-output";
10
+ import { runningWorkers } from "../chat/worker/client";
11
+ import { localFacts } from "../load/machines";
12
+ import { listQueue } from "../tasks/queue";
13
+ import { pendingCi } from "./ci";
14
+ import { bootsSince } from "./boots";
15
+ import { errorRate } from "./errors";
16
+ import type { HealthConfig } from "./config";
17
+ import {
18
+ MIN,
19
+ type ArmedFact,
20
+ type BgFact,
21
+ type HealthFacts,
22
+ type MachineFact,
23
+ type QueueFact,
24
+ } from "./types";
25
+
26
+ const sqliteMs = (v: string | null) =>
27
+ v ? Date.parse(v.includes("T") ? v : `${v.replace(" ", "T")}Z`) || 0 : 0;
28
+
29
+ // When each machine turned red, kept while it stays red. A restart forgets
30
+ // it, which only delays a trip by the threshold.
31
+ const redSince = new Map<string, number>();
32
+
33
+ export function trackRed(
34
+ id: string,
35
+ level: MachineFact["level"],
36
+ now: number
37
+ ): number | null {
38
+ if (level !== "red") {
39
+ redSince.delete(id);
40
+ return null;
41
+ }
42
+ if (!redSince.has(id)) redSince.set(id, now);
43
+ return redSince.get(id) ?? null;
44
+ }
45
+
46
+ export const resetRedTracking = () => redSince.clear();
47
+
48
+ function machines(now: number): MachineFact[] {
49
+ const l = localFacts();
50
+ return [
51
+ {
52
+ id: "local",
53
+ label: "This machine",
54
+ level: l.level,
55
+ redSince: trackRed("local", l.level, now),
56
+ load1: l.load1,
57
+ cores: l.cores,
58
+ },
59
+ ];
60
+ }
61
+
62
+ function queue(): QueueFact[] {
63
+ return listQueue()
64
+ .filter((q) => q.status === "queued")
65
+ .map((q) => ({
66
+ id: q.id,
67
+ name: q.name,
68
+ projectId: q.projectId,
69
+ workspaceId: q.workspaceId,
70
+ createdAt: sqliteMs(q.createdAt),
71
+ heldBy:
72
+ q.note ??
73
+ (q.after ? `waiting for ${q.after}` : "waiting for a free slot"),
74
+ }));
75
+ }
76
+
77
+ // Background jobs still running in a chat whose agent is alive, that have
78
+ // been going long enough to be worth a look at their output.
79
+ function backgroundJobs(now: number, c: HealthConfig): BgFact[] {
80
+ const live = new Set(runningWorkers());
81
+ if (!live.size) return [];
82
+ const sessions = db
83
+ .prepare(
84
+ `SELECT s.id, s.name, p.workspace_id FROM sessions s
85
+ LEFT JOIN projects p ON p.id = s.project_id
86
+ WHERE s.archived_at IS NULL AND s.view = 'chat'`
87
+ )
88
+ .all() as { id: string; name: string; workspace_id: string | null }[];
89
+ const out: BgFact[] = [];
90
+ for (const s of sessions) {
91
+ if (!live.has(s.id)) continue;
92
+ for (const t of itemsOfKind(s.id, "task")) {
93
+ if (t.status !== "running" || t.ambient) continue;
94
+ if (now - t.createdAt < c.bgQuietMinutes * MIN) continue;
95
+ const tail = taskOutputTail(t.taskId, t.outputFile);
96
+ out.push({
97
+ sessionId: s.id,
98
+ sessionName: s.name,
99
+ workspaceId: s.workspace_id,
100
+ taskId: t.taskId,
101
+ description: t.description,
102
+ startedAt: t.createdAt,
103
+ outputAt: tail?.updatedAt ?? null,
104
+ });
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // Every running background job, for the heartbeat a restart reads back.
111
+ export function liveBackgroundJobs(): string[] {
112
+ const live = new Set(runningWorkers());
113
+ const out: string[] = [];
114
+ for (const id of live) {
115
+ for (const t of itemsOfKind(id, "task"))
116
+ if (t.status === "running" && !t.ambient) out.push(t.description);
117
+ }
118
+ return out;
119
+ }
120
+
121
+ function armed(): ArmedFact[] {
122
+ return (
123
+ db
124
+ .prepare(
125
+ `SELECT s.id, s.name, s.merge_armed_at, s.pr_number, p.workspace_id
126
+ FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
127
+ WHERE s.merge_armed_at IS NOT NULL AND s.archived_at IS NULL
128
+ AND COALESCE(s.task_status, 'running') = 'running'`
129
+ )
130
+ .all() as {
131
+ id: string;
132
+ name: string;
133
+ merge_armed_at: string;
134
+ pr_number: number | null;
135
+ workspace_id: string | null;
136
+ }[]
137
+ ).map((r) => ({
138
+ sessionId: r.id,
139
+ name: r.name,
140
+ workspaceId: r.workspace_id,
141
+ armedAt: sqliteMs(r.merge_armed_at),
142
+ prNumber: r.pr_number,
143
+ }));
144
+ }
145
+
146
+ export function gatherFacts(now: number, c: HealthConfig): HealthFacts {
147
+ return {
148
+ now,
149
+ machines: machines(now),
150
+ queue: queue(),
151
+ bg: backgroundJobs(now, c),
152
+ armed: armed(),
153
+ ci: pendingCi(now),
154
+ boots: bootsSince(now - c.restartsWindowMinutes * MIN).map(
155
+ (b) => b.started_at
156
+ ),
157
+ errors: errorRate(now),
158
+ };
159
+ }
@@ -0,0 +1,30 @@
1
+ import { HEALTH_DEFAULTS, type HealthConfig } from "./config";
2
+ import type { HealthFacts, Trip } from "./types";
3
+
4
+ export const NOW = Date.parse("2026-10-14T18:00:00Z");
5
+ export const config = (over: Partial<HealthConfig> = {}): HealthConfig => ({
6
+ ...HEALTH_DEFAULTS,
7
+ ...over,
8
+ });
9
+
10
+ export const facts = (over: Partial<HealthFacts> = {}): HealthFacts => ({
11
+ now: NOW,
12
+ machines: [],
13
+ queue: [],
14
+ bg: [],
15
+ armed: [],
16
+ ci: [],
17
+ boots: [],
18
+ errors: { recent: 0, baselineMean: 0 },
19
+ ...over,
20
+ });
21
+
22
+ export const trip = (over: Partial<Trip> = {}): Trip => ({
23
+ check: "load-red",
24
+ subject: "local",
25
+ severity: "warn",
26
+ line: "This machine load has been red for 11m.",
27
+ detail: {},
28
+ workspaceId: null,
29
+ ...over,
30
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * What normal looks like, per area: short lines a watcher reads before it
3
+ * judges. Seeds are generic and ship with the code; anything machine- or
4
+ * person-specific is learned at run time and lives only in the local
5
+ * database, where Saad can edit or delete it.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import { AREAS, type Area } from "./types";
10
+
11
+ export const SEED_NOTES: Record<Area, string[]> = {
12
+ scheduler: [
13
+ "Load spikes while a task runs a simulator, a full build or a test suite, and is back to normal within minutes.",
14
+ "A queued task waits while the load gate is closed or the workspace's running-task slots are full; plan-only tasks never hold a slot.",
15
+ "A paused task gives its slot back.",
16
+ ],
17
+ pipeline: [
18
+ "CI duration is per repository; a run slightly over its usual time is normal, one at several times it usually is not.",
19
+ "An armed PR waits for CI, the independent review and the scope check; an armed PR whose gate keeps failing is waiting for its task, not stuck.",
20
+ "A restart during a deploy is expected; three or more within an hour is not.",
21
+ ],
22
+ tasks: [
23
+ "Builds and test suites can print nothing for a few minutes; a job quiet for much longer, or that never printed anything, deserves a look at whether its process is still running.",
24
+ "A background monitor waits for a marker line; if the job it watches failed, the marker never comes.",
25
+ ],
26
+ };
27
+
28
+ export interface NoteRow {
29
+ id: number;
30
+ area: Area;
31
+ line: string;
32
+ source: "watcher" | "saad";
33
+ created_at: number;
34
+ }
35
+
36
+ export const MAX_NOTE = 200;
37
+ const SHOWN = 40;
38
+
39
+ export function addNote(
40
+ area: Area,
41
+ line: string,
42
+ source: NoteRow["source"],
43
+ now = Date.now()
44
+ ): number | null {
45
+ const text = line.replace(/\s+/g, " ").trim().slice(0, MAX_NOTE);
46
+ if (!text || !AREAS.includes(area)) return null;
47
+ const same = db
48
+ .prepare(`SELECT 1 FROM watcher_notes WHERE area = ? AND line = ?`)
49
+ .get(area, text);
50
+ if (same) return null;
51
+ const info = db
52
+ .prepare(
53
+ `INSERT INTO watcher_notes (area, line, source, created_at)
54
+ VALUES (?, ?, ?, ?)`
55
+ )
56
+ .run(area, text, source, now);
57
+ return Number(info.lastInsertRowid);
58
+ }
59
+
60
+ export const listNotes = (area?: Area) =>
61
+ (area
62
+ ? db
63
+ .prepare(`SELECT * FROM watcher_notes WHERE area = ? ORDER BY id`)
64
+ .all(area)
65
+ : db.prepare(`SELECT * FROM watcher_notes ORDER BY id`).all()) as NoteRow[];
66
+
67
+ export const deleteNote = (id: number): boolean =>
68
+ db.prepare(`DELETE FROM watcher_notes WHERE id = ?`).run(id).changes > 0;
69
+
70
+ // The seeds, then the newest learned lines.
71
+ export function notesFor(area: Area): string[] {
72
+ const learned = listNotes(area)
73
+ .slice(-SHOWN)
74
+ .map((n) => n.line);
75
+ return [...SEED_NOTES[area], ...learned];
76
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * piece-dup: a plan piece about to start when matching work has already
3
+ * merged. The release guard (lib/plans/release.ts) only knows PRs from the
4
+ * same feature; this reads every merged task in the piece's project since
5
+ * the feature began, so work that merged through a separately started task
6
+ * is seen. Report only: the piece still starts.
7
+ */
8
+
9
+ import { db } from "../db";
10
+ import { close, openAnomalies, recordTrips } from "./store";
11
+ import { healthConfig, type HealthConfig } from "./config";
12
+ import { matchingMerge, type MergedWork } from "./detectors";
13
+ import type { Trip } from "./types";
14
+
15
+ interface PieceRef {
16
+ id: number;
17
+ name: string;
18
+ slice: string;
19
+ project_id: string;
20
+ }
21
+
22
+ interface FeatureRef {
23
+ id: string;
24
+ name: string;
25
+ workspace_id: string;
26
+ plan_session_id: string;
27
+ created_at: string;
28
+ }
29
+
30
+ export function mergedSince(
31
+ feature: FeatureRef,
32
+ piece: PieceRef,
33
+ ownSessionIds: string[]
34
+ ): MergedWork[] {
35
+ const own = new Set([feature.plan_session_id, ...ownSessionIds]);
36
+ const since = feature.created_at.includes("T")
37
+ ? feature.created_at
38
+ : `${feature.created_at.replace(" ", "T")}Z`;
39
+ return (
40
+ db
41
+ .prepare(
42
+ `SELECT id, name, pr_number, task_prompt, updated_at FROM sessions
43
+ WHERE project_id = ? AND task_status = 'merged'
44
+ AND updated_at >= datetime(?)`
45
+ )
46
+ .all(piece.project_id, since) as {
47
+ id: string;
48
+ name: string;
49
+ pr_number: number | null;
50
+ task_prompt: string | null;
51
+ }[]
52
+ )
53
+ .filter((s) => !own.has(s.id))
54
+ .map((s) => ({
55
+ sessionId: s.id,
56
+ prNumber: s.pr_number,
57
+ text: `${s.name} ${(s.task_prompt ?? "").slice(0, 600)}`,
58
+ }));
59
+ }
60
+
61
+ export function duplicateTrip(
62
+ feature: FeatureRef,
63
+ piece: PieceRef,
64
+ merged: MergedWork[],
65
+ c: HealthConfig
66
+ ): Trip | null {
67
+ const hit = matchingMerge(piece, merged, c.pieceDupOverlap);
68
+ if (!hit) return null;
69
+ const pct = Math.round(hit.score * 100);
70
+ return {
71
+ check: "piece-dup",
72
+ subject: `piece:${piece.id}`,
73
+ severity: "warn",
74
+ line: `Plan piece "${piece.name}" of feature "${feature.name}" is starting, but ${hit.work.prNumber ? `PR #${hit.work.prNumber}` : "a task"} already merged in the same project and shares ${pct}% of its wording.`,
75
+ detail: {
76
+ mergedSession: hit.work.sessionId,
77
+ prNumber: hit.work.prNumber,
78
+ pct,
79
+ },
80
+ workspaceId: feature.workspace_id,
81
+ };
82
+ }
83
+
84
+ // Called as a piece is released. Never throws: it must not hold a start.
85
+ export function reportDuplicatePiece(
86
+ feature: FeatureRef,
87
+ piece: PieceRef,
88
+ ownSessionIds: string[],
89
+ now = Date.now()
90
+ ): void {
91
+ try {
92
+ const c = healthConfig();
93
+ const trip = duplicateTrip(
94
+ feature,
95
+ piece,
96
+ mergedSince(feature, piece, ownSessionIds),
97
+ c
98
+ );
99
+ if (trip) recordTrips([trip], [], now);
100
+ } catch (error) {
101
+ console.error("[health] piece check failed:", error);
102
+ }
103
+ }
104
+
105
+ // A piece that merged or was dropped no longer needs the look.
106
+ export function closeResolvedPieces(now: number): number {
107
+ let closed = 0;
108
+ for (const row of openAnomalies()) {
109
+ if (row.check_id !== "piece-dup") continue;
110
+ const id = Number(row.subject.replace("piece:", ""));
111
+ const piece = db
112
+ .prepare(`SELECT status FROM feature_pieces WHERE id = ?`)
113
+ .get(id) as { status: string } | undefined;
114
+ if (!piece || piece.status === "merged" || piece.status === "dropped") {
115
+ close(row.id, "resolved", now);
116
+ closed++;
117
+ }
118
+ }
119
+ return closed;
120
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Where an anomaly is heard: one line in the orchestrator chat, and the
3
+ * phone only when the watcher says it needs the person. Each is sent once
4
+ * per anomaly (keys carry the anomaly id and severity), so one that keeps
5
+ * tripping is one line.
6
+ */
7
+
8
+ import { notifyPhone } from "../notify";
9
+ import { listOrchestrators } from "../orchestrator/home";
10
+ import { queueEventOnce } from "../orchestrator/events";
11
+ import { redact, untrusted } from "../orchestrator/untrusted";
12
+ import { duration } from "./detectors";
13
+ import { markDelivered, markPhoned, verdictOf } from "./store";
14
+ import { db } from "../db";
15
+ import { AREA_LABELS, MIN, type AnomalyRow, type Verdict } from "./types";
16
+
17
+ // Phone pushes a day, whatever the watchers say.
18
+ export const PHONE_CAP_PER_DAY = 5;
19
+
20
+ export interface RouteDeps {
21
+ queue: typeof queueEventOnce;
22
+ phone: typeof notifyPhone;
23
+ workspaces: () => string[];
24
+ }
25
+
26
+ export const realRoute: RouteDeps = {
27
+ queue: queueEventOnce,
28
+ phone: notifyPhone,
29
+ workspaces: () =>
30
+ listOrchestrators()
31
+ .filter((o) => !o.archived_at && o.workspace_id)
32
+ .map((o) => o.workspace_id as string),
33
+ };
34
+
35
+ export const phoneLine = (line: string): string => {
36
+ const safe = redact(line).replace(/\s+/g, " ").trim();
37
+ return safe.length > 160 ? `${safe.slice(0, 159)}…` : safe;
38
+ };
39
+
40
+ export function chatLine(row: AnomalyRow, v: Verdict): string {
41
+ const head = `Health (${AREA_LABELS[row.area].toLowerCase()}${v.unjudged ? ", unjudged" : ""}):`;
42
+ // A detector line can carry a job description or a piece name an agent wrote.
43
+ const line = untrusted("health detector", row.line);
44
+ if (v.unjudged) return `${head} ${line}`;
45
+ const said = [v.cause, v.action ? `Suggest: ${v.action}` : ""]
46
+ .filter(Boolean)
47
+ .join(" ");
48
+ return `${head} ${line} ${untrusted("health watcher", said)}`;
49
+ }
50
+
51
+ function phonedToday(now: number): number {
52
+ return (
53
+ db
54
+ .prepare(
55
+ `SELECT COUNT(*) AS n FROM health_anomalies WHERE phoned_at >= ?`
56
+ )
57
+ .get(now - 24 * 60 * MIN) as { n: number }
58
+ ).n;
59
+ }
60
+
61
+ export function deliver(
62
+ row: AnomalyRow,
63
+ v: Verdict,
64
+ now: number,
65
+ deps: RouteDeps = realRoute
66
+ ): void {
67
+ // The watcher judged it normal: it's in the panel, not in the chat.
68
+ if (!v.real) {
69
+ markDelivered(row.id, now);
70
+ return;
71
+ }
72
+ const targets = row.workspace_id ? [row.workspace_id] : deps.workspaces();
73
+ const urgent = v.saadOnly || row.severity === "high";
74
+ const line = chatLine(row, v);
75
+ for (const ws of targets)
76
+ deps.queue(
77
+ ws,
78
+ `health:${row.id}:${row.severity}`,
79
+ row.subject,
80
+ line,
81
+ now,
82
+ !urgent
83
+ );
84
+ markDelivered(row.id, now);
85
+ if (v.saadOnly && !row.phoned_at && phonedToday(now) < PHONE_CAP_PER_DAY) {
86
+ // Nothing a model wrote; the detector's line, redacted and clipped.
87
+ deps.phone(
88
+ "health",
89
+ `${phoneLine(row.line)} It needs you; the details are in the orchestrator chat.`,
90
+ {
91
+ category: "system",
92
+ key: `health:${row.id}`,
93
+ collapse: `health:${row.id}`,
94
+ }
95
+ );
96
+ markPhoned(row.id, now);
97
+ }
98
+ }
99
+
100
+ // After it clears, one short line, only if the chat ever heard of it.
101
+ export function deliverClosed(
102
+ row: AnomalyRow,
103
+ now: number,
104
+ deps: RouteDeps = realRoute
105
+ ): void {
106
+ if (!row.delivered_at || row.close_reason !== "cleared") return;
107
+ const v = verdictOf(row);
108
+ if (!v?.real) return;
109
+ const text = `Health (${AREA_LABELS[row.area].toLowerCase()}): cleared after ${duration((row.closed_at ?? now) - row.opened_at)}: ${row.check_id} on ${row.subject}.`;
110
+ const targets = row.workspace_id ? [row.workspace_id] : deps.workspaces();
111
+ for (const ws of targets)
112
+ deps.queue(ws, `health:${row.id}:closed`, row.subject, text, now, true);
113
+ }