@saadnvd1/agent-os 0.3.723 → 0.3.731

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 (150) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/ports/route.test.ts +43 -0
  3. package/app/api/bus/ports/route.ts +35 -0
  4. package/app/api/loops/route.ts +17 -0
  5. package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
  6. package/app/api/sessions/[id]/export/turn/route.ts +33 -0
  7. package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
  8. package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
  9. package/app/api/tasks/[id]/loop/next/route.ts +25 -0
  10. package/app/api/tasks/[id]/loop/route.ts +72 -0
  11. package/app/api/tasks/[id]/loop/run/route.ts +12 -0
  12. package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
  13. package/app/api/tasks/[id]/mode/route.ts +29 -0
  14. package/app/api/tasks/[id]/report/draft/route.ts +54 -0
  15. package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
  16. package/app/api/tasks/[id]/report/route.ts +39 -0
  17. package/app/api/workspaces/[id]/mode/route.ts +23 -0
  18. package/app/api/workspaces/[id]/reports/route.ts +17 -0
  19. package/app/page.tsx +8 -0
  20. package/components/Analyst/PairChip.tsx +131 -0
  21. package/components/Analyst/PairSwitch.tsx +76 -0
  22. package/components/Analyst/ReviewCard.tsx +272 -0
  23. package/components/Analyst/SignalChips.tsx +39 -0
  24. package/components/Analyst/StartAnalystSheet.tsx +109 -0
  25. package/components/Analyst/cardState.test.ts +109 -0
  26. package/components/Analyst/cardState.ts +95 -0
  27. package/components/Analyst/pair.ts +81 -0
  28. package/components/Chat/Code.tsx +3 -0
  29. package/components/Chat/Messages.tsx +8 -1
  30. package/components/Chat/TurnCopy.tsx +84 -0
  31. package/components/Chat/itemContext.ts +14 -0
  32. package/components/Draft/DraftChips.tsx +10 -0
  33. package/components/Draft/DraftPanel.tsx +7 -0
  34. package/components/Pane/DesktopTabBar.tsx +6 -0
  35. package/components/Pane/MobileTabBar.tsx +17 -0
  36. package/components/Pane/MobileViewPicker.tsx +4 -0
  37. package/components/Pane/PaneMenu.test.ts +21 -0
  38. package/components/Pane/PaneMenu.tsx +16 -0
  39. package/components/Pane/PaneViewToggle.tsx +12 -0
  40. package/components/Pane/index.tsx +19 -0
  41. package/components/Report/DraftCard.tsx +89 -0
  42. package/components/Report/EstimateBar.tsx +51 -0
  43. package/components/Report/EvidenceSheet.tsx +98 -0
  44. package/components/Report/ModeChooser.tsx +43 -0
  45. package/components/Report/ReportView.tsx +289 -0
  46. package/components/SessionList/GroupShelves.tsx +57 -22
  47. package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
  48. package/components/SessionList/SessionRow.tsx +12 -1
  49. package/components/SessionList/SessionRowMenu.tsx +6 -0
  50. package/components/SessionList/SidebarContextMenu.test.ts +21 -1
  51. package/components/SessionList/SidebarContextMenu.tsx +33 -9
  52. package/components/SessionList/index.tsx +11 -2
  53. package/components/SessionList/useGroupDrag.ts +83 -0
  54. package/components/Sessions/RolePill.tsx +7 -0
  55. package/components/Tasks/Loop/LoopButton.tsx +74 -0
  56. package/components/Tasks/Loop/LoopSetup.tsx +146 -0
  57. package/components/Tasks/Loop/LoopView.tsx +350 -0
  58. package/components/Tasks/Loop/labels.ts +17 -0
  59. package/components/Tasks/TaskRow.tsx +2 -0
  60. package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
  61. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  62. package/components/ui/segmented.tsx +55 -0
  63. package/components/ui/status-pill.tsx +3 -0
  64. package/contexts/PaneContext.tsx +21 -1
  65. package/data/exports/queries.ts +11 -0
  66. package/data/loops/index.ts +3 -0
  67. package/data/loops/keys.ts +4 -0
  68. package/data/loops/mutations.ts +68 -0
  69. package/data/loops/queries.ts +40 -0
  70. package/data/push/topics.ts +6 -0
  71. package/data/reports/index.ts +115 -0
  72. package/lib/agents/brief.ts +1 -1
  73. package/lib/analyst/forward.ts +1 -1
  74. package/lib/db/changes.ts +7 -0
  75. package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
  76. package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
  77. package/lib/done/index.ts +2 -0
  78. package/lib/drafts.ts +2 -0
  79. package/lib/export/markdown.ts +7 -1
  80. package/lib/export/turn.ts +48 -0
  81. package/lib/health/config.ts +2 -0
  82. package/lib/health/env/classes.ts +14 -1
  83. package/lib/health/port-wait-tick.ts +222 -0
  84. package/lib/health/port-wait.ts +160 -0
  85. package/lib/health/tick.ts +9 -2
  86. package/lib/health/types.ts +2 -0
  87. package/lib/loops/analyse.ts +46 -0
  88. package/lib/loops/analyst-prompt.ts +203 -0
  89. package/lib/loops/checkCriteria.ts +114 -0
  90. package/lib/loops/compactEvidence.ts +140 -0
  91. package/lib/loops/controls.ts +317 -0
  92. package/lib/loops/decideStop.ts +113 -0
  93. package/lib/loops/deps.ts +128 -0
  94. package/lib/loops/diffMap.ts +39 -0
  95. package/lib/loops/draftCriteria.ts +71 -0
  96. package/lib/loops/gatherEvidence.ts +265 -0
  97. package/lib/loops/hooks.ts +40 -0
  98. package/lib/loops/index.ts +7 -0
  99. package/lib/loops/route.ts +83 -0
  100. package/lib/loops/runCycle.ts +323 -0
  101. package/lib/loops/screenInstruction.ts +59 -0
  102. package/lib/loops/sendInstruction.ts +83 -0
  103. package/lib/loops/snapshot.ts +38 -0
  104. package/lib/loops/stopLoop.ts +86 -0
  105. package/lib/loops/store.ts +481 -0
  106. package/lib/loops/summary.ts +27 -0
  107. package/lib/loops/tickLoops.ts +141 -0
  108. package/lib/loops/types.ts +223 -0
  109. package/lib/loops/world.test-helpers.ts +223 -0
  110. package/lib/models/plan-step.ts +4 -1
  111. package/lib/orchestrator/digest.ts +2 -1
  112. package/lib/orchestrator/loop-guard.ts +5 -2
  113. package/lib/orchestrator/pr-wake.ts +4 -0
  114. package/lib/orchestrator/watcher.ts +8 -1
  115. package/lib/panes.ts +52 -0
  116. package/lib/plans/acceptance.ts +23 -0
  117. package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
  118. package/lib/ports/ensurePorts.ts +138 -0
  119. package/lib/ports/findHardcodedPorts.ts +116 -0
  120. package/lib/ports/index.ts +15 -0
  121. package/lib/ports/listListeners.ts +134 -0
  122. package/lib/ports/reassignPort.ts +45 -0
  123. package/lib/ports/types.ts +62 -0
  124. package/lib/ports/whoHolds.ts +155 -0
  125. package/lib/project-config/brief.ts +4 -2
  126. package/lib/project-config/session.ts +19 -3
  127. package/lib/reports/drafts.ts +87 -0
  128. package/lib/reports/end-hook.ts +17 -0
  129. package/lib/reports/estimate.ts +107 -0
  130. package/lib/reports/evidence/build.ts +193 -0
  131. package/lib/reports/evidence/gather.ts +229 -0
  132. package/lib/reports/fixtures.ts +17 -0
  133. package/lib/reports/generate.ts +159 -0
  134. package/lib/reports/hooks.ts +30 -0
  135. package/lib/reports/mode.ts +65 -0
  136. package/lib/reports/model.ts +115 -0
  137. package/lib/reports/read.ts +26 -0
  138. package/lib/reports/store.ts +180 -0
  139. package/lib/reports/template.ts +117 -0
  140. package/lib/reports/types.ts +135 -0
  141. package/lib/reports/validate.ts +142 -0
  142. package/lib/sidebar/groupDrop.ts +36 -0
  143. package/lib/sidebar/row-menu.ts +9 -0
  144. package/lib/tasks/contract/routes.ts +5 -0
  145. package/lib/tasks/contract/types.ts +3 -0
  146. package/lib/tasks/finish.ts +2 -0
  147. package/lib/tasks/move-carry.ts +1 -0
  148. package/lib/windows-ports.ts +36 -18
  149. package/package.json +2 -2
  150. package/stores/analystUi.ts +19 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * One turn as Markdown for the clipboard: the same model and serializer as a
3
+ * session export (build.ts, markdown.ts), redacted for secrets, cut to one turn.
4
+ */
5
+
6
+ import type { ChatItem } from "../chat/events";
7
+ import type { ShareContext } from "../share/redact";
8
+ import { BadShare } from "../share/service";
9
+ import { buildDoc, type BuildInput, type ExportTurn } from "./build";
10
+ import { itemMd } from "./markdown";
11
+ import { groupTurns } from "./turns";
12
+
13
+ export interface TurnCopy {
14
+ /** Reasoning, tool calls and their output, errors and the answer, in order. */
15
+ full: string;
16
+ /** The turn's last agent message. */
17
+ response: string;
18
+ }
19
+
20
+ /** The turn body: every item after the message that began it. */
21
+ export function turnMarkdown(turn: ExportTurn): TurnCopy {
22
+ // The first item is the message that began the turn: already on screen.
23
+ const body = turn.userId ? turn.items.slice(1) : turn.items;
24
+ const parts = body.map(itemMd);
25
+ if (turn.ended) parts.push(`*Turn ended early: ${turn.ended}*`);
26
+ if (turn.interrupted) parts.push("*Turn stopped before it finished*");
27
+ const last = [...turn.items].reverse().find((i) => i.kind === "assistant");
28
+ return { full: parts.join("\n\n"), response: last?.text ?? "" };
29
+ }
30
+
31
+ /** The turn that begins with message `startId`. 404 as BadShare. */
32
+ export function copyTurn(
33
+ items: ChatItem[],
34
+ startId: string,
35
+ ctx: ShareContext,
36
+ meta: BuildInput["meta"]
37
+ ): TurnCopy {
38
+ const turn = groupTurns(items).find((t) => t.items[0]?.id === startId);
39
+ if (!turn) throw new BadShare("Turn not found", 404);
40
+ const doc = buildDoc({
41
+ items: turn.items,
42
+ meta,
43
+ mode: "full",
44
+ redact: "secrets",
45
+ ctx,
46
+ });
47
+ return turnMarkdown(doc.turns[0]);
48
+ }
@@ -8,6 +8,7 @@ export const HEALTH_DEFAULTS = {
8
8
  loadRedMinutes: 10,
9
9
  queueHeldMinutes: 60,
10
10
  bgQuietMinutes: 20,
11
+ portWaitMinutes: 10,
11
12
  pieceDupOverlap: 0.4,
12
13
  restartsMax: 2,
13
14
  restartsWindowMinutes: 60,
@@ -33,6 +34,7 @@ export const HEALTH_LABELS: Record<HealthKey, string> = {
33
34
  loadRedMinutes: "Machine load red for (minutes)",
34
35
  queueHeldMinutes: "Queued task held for (minutes)",
35
36
  bgQuietMinutes: "Background job quiet for (minutes)",
37
+ portWaitMinutes: "Waiting on a port someone else holds for (minutes)",
36
38
  pieceDupOverlap: "Plan piece matches merged work (0 to 1 overlap)",
37
39
  restartsMax: "Server restarts allowed per window",
38
40
  restartsWindowMinutes: "Restart window (minutes)",
@@ -11,6 +11,19 @@ const USE_ENV =
11
11
  "run it through the repo's own environment (`uv run …` when it has a pyproject.toml or uv.lock, else its .venv/bin/python), not a bare `python3`";
12
12
 
13
13
  export const ENV_CLASSES: readonly EnvClass[] = [
14
+ {
15
+ id: "port-in-use",
16
+ label: "Port taken by another process",
17
+ scope: "shell",
18
+ match: [
19
+ /\bEADDRINUSE\b/,
20
+ /^(?:\S+Error: )?(?:\[Errno \d+\] )?Address already in use\b/i,
21
+ /^(?:Error: )?Port \d+ is already in use\b/,
22
+ /^Something is already running on port \d+/,
23
+ /\bport is already allocated\b/,
24
+ ],
25
+ fix: "A port this command needs is taken by another process on this machine. Don't wait for it and don't stop it: run `aos port <that port>` to see who holds it and get a free one, then start on that one (pass it with --port or PORT=). If you need that exact port, tell the orchestrator.",
26
+ },
14
27
  {
15
28
  id: "python-version",
16
29
  label: "Python too old for the code",
@@ -116,4 +129,4 @@ export const SHELL_TOOLS = new Set([
116
129
  // Commands that show text somebody else wrote: an error in their output is
117
130
  // in the data, not something this run hit.
118
131
  export const READERS =
119
- /^(?:grep|egrep|rg|ag|cat|head|tail|less|more|sed\s+-n|awk|jq|sqlite3|journalctl|echo|printf|aos\s+(?:history|inbox)|sm\s+logs|gh\s+run\s+view|git\s+(?:log|show|diff|grep))\b/;
132
+ /^(?:grep|egrep|rg|ag|cat|head|tail|less|more|ls|wc|sed\s+-n|awk|jq|sqlite3|journalctl|echo|printf|aos\s+(?:history|inbox)|sm\s+logs|gh\s+run\s+view|git\s+(?:log|show|diff|grep))\b/;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The I/O half of port-wait (port-wait.ts decides): once a minute, find the
3
+ * sessions' shell loops that have waited on a port for long enough, ask who
4
+ * holds that port, and when it's someone else tell the agent and its
5
+ * orchestrator once per holder. Nothing is stopped: a sleeping loop costs
6
+ * nothing, and the agent ends its own job. Each machine judges its own
7
+ * sessions, since ports only meet inside one machine's network.
8
+ */
9
+
10
+ import { execFile } from "child_process";
11
+ import { sendMessage } from "../bus";
12
+ import { db } from "../db";
13
+ import { readSnapshot } from "../load/usage";
14
+ import { queueEventOnce } from "../orchestrator/events";
15
+ import { untrusted } from "../orchestrator/untrusted";
16
+ import {
17
+ describeHolder,
18
+ judgePort,
19
+ listListeners,
20
+ localPortRows,
21
+ sessionPorts,
22
+ type Holder,
23
+ } from "../ports";
24
+ import { cwdsOf } from "../ports/listListeners";
25
+ import type { HealthConfig } from "./config";
26
+ import {
27
+ findPortWaits,
28
+ parsePsArgs,
29
+ portWaitLine,
30
+ portWaitNote,
31
+ waitedPorts,
32
+ type ArgsProc,
33
+ type WaitSession,
34
+ } from "./port-wait";
35
+ import { close, recordTrips } from "./store";
36
+ import { MIN, type Trip } from "./types";
37
+
38
+ export interface PortWaitDeps {
39
+ procs: () => Promise<ArgsProc[] | null>;
40
+ panes: () => Promise<{ tmux: string; pid: number }[]>;
41
+ cwds: (pids: number[]) => Promise<Map<number, string>>;
42
+ listeners: typeof listListeners;
43
+ portRows: typeof localPortRows;
44
+ sessions: () => WaitSession[];
45
+ tell: (sessionId: string, body: string) => Promise<void>;
46
+ event: (
47
+ workspaceId: string,
48
+ key: string,
49
+ subject: string,
50
+ line: string,
51
+ now: number
52
+ ) => void;
53
+ record: (trip: Trip, now: number) => void;
54
+ }
55
+
56
+ const readArgs = (): Promise<ArgsProc[] | null> =>
57
+ new Promise((resolve) =>
58
+ execFile(
59
+ "ps",
60
+ ["-Ao", "pid=,ppid=,etime=,args="],
61
+ { timeout: 5000, maxBuffer: 16 << 20 },
62
+ (err, out) => {
63
+ const rows = err ? [] : parsePsArgs(out);
64
+ resolve(rows.length ? rows : null);
65
+ }
66
+ )
67
+ );
68
+
69
+ function localSessions(): WaitSession[] {
70
+ return (
71
+ db
72
+ .prepare(
73
+ `SELECT s.id, s.name, p.workspace_id, s.tmux_name, s.worktree_path
74
+ FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
75
+ WHERE s.archived_at IS NULL AND (s.host_id IS NULL OR s.host_id = 'local')`
76
+ )
77
+ .all() as {
78
+ id: string;
79
+ name: string;
80
+ workspace_id: string | null;
81
+ tmux_name: string | null;
82
+ worktree_path: string | null;
83
+ }[]
84
+ ).map((r) => ({
85
+ id: r.id,
86
+ name: r.name,
87
+ workspaceId: r.workspace_id,
88
+ tmuxName: r.tmux_name,
89
+ worktree: r.worktree_path,
90
+ }));
91
+ }
92
+
93
+ export const realPortWait: PortWaitDeps = {
94
+ procs: readArgs,
95
+ panes: async () => (await readSnapshot()).panes,
96
+ cwds: cwdsOf,
97
+ listeners: listListeners,
98
+ portRows: localPortRows,
99
+ sessions: localSessions,
100
+ tell: async (sessionId, body) => {
101
+ await sendMessage({
102
+ fromId: null,
103
+ to: sessionId,
104
+ body,
105
+ fromLabel: "AgentOS health",
106
+ origin: { kind: "system", label: "Health", body },
107
+ });
108
+ },
109
+ event: (ws, key, subject, line, now) => {
110
+ queueEventOnce(ws, key, subject, line, now);
111
+ },
112
+ record: (trip, now) => {
113
+ const { opened } = recordTrips([trip], [], now);
114
+ // History only: the agent and orchestrator are told directly.
115
+ for (const row of opened) close(row.id, "told", now);
116
+ },
117
+ };
118
+
119
+ const holderKey = (h: Holder) =>
120
+ h.kind === "session"
121
+ ? `s:${h.sessionId}`
122
+ : h.kind === "dev-server"
123
+ ? `d:${h.id}`
124
+ : `${h.kind}:${h.pid}`;
125
+
126
+ // session:port -> the holder it was told about. A wait that ends, or a new
127
+ // holder, re-arms it.
128
+ const told = new Map<string, string>();
129
+ let running = false;
130
+
131
+ export function resetPortWait(): void {
132
+ told.clear();
133
+ running = false;
134
+ }
135
+
136
+ export async function portWaitTick(
137
+ now: number,
138
+ c: HealthConfig,
139
+ deps: PortWaitDeps = realPortWait
140
+ ): Promise<void> {
141
+ const minMs = c.portWaitMinutes * MIN;
142
+ if (!minMs || running) return;
143
+ running = true;
144
+ try {
145
+ const procs = await deps.procs();
146
+ if (!procs) return;
147
+ const old = procs.filter(
148
+ (p) => p.ageMs >= minMs && waitedPorts(p.args).length
149
+ );
150
+ if (!old.length) {
151
+ told.clear();
152
+ return;
153
+ }
154
+ const [panes, cwds] = await Promise.all([
155
+ deps.panes(),
156
+ deps.cwds(old.map((p) => p.pid)),
157
+ ]);
158
+ const waits = findPortWaits(procs, deps.sessions(), panes, cwds, minMs);
159
+ const live = new Set(waits.map((w) => `${w.sessionId}:${w.port}`));
160
+ for (const k of told.keys()) if (!live.has(k)) told.delete(k);
161
+ if (!waits.length) return;
162
+ const listeners = await deps.listeners();
163
+ if (!listeners) return;
164
+ const { sessions, devServers } = deps.portRows();
165
+ for (const w of waits) {
166
+ const check = judgePort(
167
+ w.sessionId,
168
+ null,
169
+ w.port,
170
+ listeners,
171
+ sessions,
172
+ devServers
173
+ );
174
+ if (!check.holder || check.mine) continue;
175
+ const key = `${w.sessionId}:${w.port}`;
176
+ const who = holderKey(check.holder);
177
+ if (told.get(key) === who) continue;
178
+ told.set(key, who);
179
+ const holder = describeHolder(check.holder);
180
+ const line = portWaitLine(w, holder);
181
+ try {
182
+ deps.record(
183
+ {
184
+ check: "port-wait",
185
+ subject: key,
186
+ severity: "warn",
187
+ line,
188
+ detail: {
189
+ sessionId: w.sessionId,
190
+ port: w.port,
191
+ pid: w.pid,
192
+ holder: check.holder,
193
+ },
194
+ workspaceId: w.workspaceId,
195
+ },
196
+ now
197
+ );
198
+ } catch (error) {
199
+ console.error("[health] port-wait record:", error);
200
+ }
201
+ if (w.workspaceId)
202
+ deps.event(
203
+ w.workspaceId,
204
+ `port-wait:${key}:${who}`,
205
+ w.sessionId,
206
+ // Session names and commands are agents' own text.
207
+ `Health (tasks): ${untrusted("health detector", line)}`,
208
+ now
209
+ );
210
+ try {
211
+ await deps.tell(
212
+ w.sessionId,
213
+ portWaitNote(w, holder, sessionPorts(w.sessionId))
214
+ );
215
+ } catch (error) {
216
+ console.error("[health] port-wait note:", error);
217
+ }
218
+ }
219
+ } finally {
220
+ running = false;
221
+ }
222
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * port-wait, the deciding half (port-wait-tick.ts does the I/O): a shell
3
+ * loop that waits on a port someone else holds can't end, since that holder
4
+ * won't free it for the agent. Sleeping loops use no CPU, so bg-spin never
5
+ * sees them; this names the holder and the way out instead.
6
+ */
7
+
8
+ import { claimTrees } from "../load/usage";
9
+ import { MIN } from "./types";
10
+
11
+ export const PORT_WAIT_MIN_MS = 10 * MIN;
12
+
13
+ const LOOP =
14
+ /\b(?:until|while)\b[\s\S]*?\b(?:curl|wget|nc|lsof|ss|netstat|http)\b|\bwait-on\b|\bwait-port\b/;
15
+ const PORT_REFS = [
16
+ /(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):(\d{2,5})\b/g,
17
+ /\btcp:(\d{2,5})\b/g,
18
+ /\s-i\s*:(\d{2,5})\b/g,
19
+ /\b[sd]port\s*=\s*:(\d{2,5})\b/g,
20
+ /\bnc\s+(?:-\w+\s+)*\S+\s+(\d{2,5})\b/g,
21
+ ];
22
+
23
+ // The ports a command waits on, when it is a wait loop at all.
24
+ export function waitedPorts(args: string): number[] {
25
+ if (!LOOP.test(args)) return [];
26
+ const ports = new Set<number>();
27
+ for (const re of PORT_REFS)
28
+ for (const m of args.matchAll(re)) {
29
+ const p = Number(m[1]);
30
+ if (p > 0 && p <= 65535) ports.add(p);
31
+ }
32
+ return [...ports];
33
+ }
34
+
35
+ // ps's etime, `[[dd-]hh:]mm:ss`, in seconds.
36
+ export function parseEtime(s: string): number | null {
37
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(s.trim());
38
+ if (!m) return null;
39
+ const [, d, h, mm, ss] = m;
40
+ return (
41
+ ((Number(d ?? 0) * 24 + Number(h ?? 0)) * 60 + Number(mm)) * 60 + Number(ss)
42
+ );
43
+ }
44
+
45
+ export interface ArgsProc {
46
+ pid: number;
47
+ ppid: number;
48
+ ageMs: number;
49
+ args: string;
50
+ }
51
+
52
+ // `ps -Ao pid=,ppid=,etime=,args=`: args is last and holds spaces.
53
+ export function parsePsArgs(out: string): ArgsProc[] {
54
+ const rows: ArgsProc[] = [];
55
+ for (const line of out.split("\n")) {
56
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
57
+ if (!m) continue;
58
+ const age = parseEtime(m[3]);
59
+ if (age === null) continue;
60
+ rows.push({
61
+ pid: Number(m[1]),
62
+ ppid: Number(m[2]),
63
+ ageMs: age * 1000,
64
+ args: m[4],
65
+ });
66
+ }
67
+ return rows;
68
+ }
69
+
70
+ export interface WaitSession {
71
+ id: string;
72
+ name: string;
73
+ workspaceId: string | null;
74
+ tmuxName: string | null;
75
+ worktree: string | null;
76
+ }
77
+
78
+ export interface PortWait {
79
+ sessionId: string;
80
+ sessionName: string;
81
+ workspaceId: string | null;
82
+ pid: number;
83
+ port: number;
84
+ args: string;
85
+ waitedMs: number;
86
+ }
87
+
88
+ const inside = (cwd: string, dir: string) =>
89
+ cwd === dir || cwd.startsWith(`${dir.replace(/\/$/, "")}/`);
90
+
91
+ /**
92
+ * Each session's waits on a port for at least `minMs`, one per session and
93
+ * port (a loop's wrapper shells are folded into its oldest). A process is
94
+ * the session's when it runs in its worktree, or under its tmux pane.
95
+ */
96
+ export function findPortWaits(
97
+ procs: ArgsProc[],
98
+ sessions: WaitSession[],
99
+ panes: { tmux: string; pid: number }[],
100
+ cwds: Map<number, string>,
101
+ minMs: number
102
+ ): PortWait[] {
103
+ const byTmux = new Map(
104
+ sessions.filter((s) => s.tmuxName).map((s) => [s.tmuxName!, s])
105
+ );
106
+ const roots = panes
107
+ .filter((p) => byTmux.has(p.tmux))
108
+ .map((p) => ({ key: byTmux.get(p.tmux)!.id, pid: p.pid }));
109
+ const owner = claimTrees(
110
+ procs.map((p) => ({ pid: p.pid, ppid: p.ppid, pcpu: 0, rss: 0 })),
111
+ roots
112
+ );
113
+ const out = new Map<string, PortWait>();
114
+ for (const p of procs) {
115
+ if (p.ageMs < minMs) continue;
116
+ const ports = waitedPorts(p.args);
117
+ if (!ports.length) continue;
118
+ const cwd = cwds.get(p.pid);
119
+ const s =
120
+ (cwd && sessions.find((x) => x.worktree && inside(cwd, x.worktree))) ||
121
+ sessions.find((x) => x.id === owner.get(p.pid));
122
+ if (!s) continue;
123
+ for (const port of ports) {
124
+ const key = `${s.id}:${port}`;
125
+ const had = out.get(key);
126
+ if (had && had.waitedMs >= p.ageMs) continue;
127
+ out.set(key, {
128
+ sessionId: s.id,
129
+ sessionName: s.name,
130
+ workspaceId: s.workspaceId,
131
+ pid: p.pid,
132
+ port,
133
+ args: p.args,
134
+ waitedMs: p.ageMs,
135
+ });
136
+ }
137
+ }
138
+ return [...out.values()];
139
+ }
140
+
141
+ const short = (args: string) =>
142
+ args.length > 120 ? `${args.slice(0, 117)}...` : args;
143
+
144
+ export function portWaitNote(
145
+ w: PortWait,
146
+ holder: string,
147
+ own: Record<string, number> | null
148
+ ): string {
149
+ const mins = Math.round(w.waitedMs / MIN);
150
+ const mine =
151
+ own && Object.keys(own).length
152
+ ? ` Your ports are ${Object.entries(own)
153
+ .map(([n, p]) => `${n}=${p}`)
154
+ .join(", ")}.`
155
+ : "";
156
+ return `You've waited ${mins} min for port ${w.port} (pid ${w.pid}: \`${short(w.args)}\`). It's held by ${holder}, which isn't yours and won't free it for you, so that wait can't end. Stop it (\`kill ${w.pid}\`, your own job), don't stop the holder, and run \`aos port ${w.port}\` for a free port of your own to start on.${mine} If you need ${w.port} itself, tell the orchestrator.`;
157
+ }
158
+
159
+ export const portWaitLine = (w: PortWait, holder: string) =>
160
+ `${w.sessionName} has waited ${Math.round(w.waitedMs / MIN)} min for port ${w.port}, held by ${holder}. It was told to move to its own port.`;
@@ -11,6 +11,7 @@ import { healthConfig, type HealthConfig } from "./config";
11
11
  import { runDetectors } from "./detectors";
12
12
  import { gatherFacts, liveBackgroundJobs } from "./facts";
13
13
  import { envTick, realEnv, type EnvDeps } from "./env/envTick";
14
+ import { portWaitTick } from "./port-wait-tick";
14
15
  import { spinTick } from "./spin-tick";
15
16
  import { addNote } from "./notes";
16
17
  import { closeResolvedPieces } from "./pieces";
@@ -45,9 +46,9 @@ export const MAX_WAKES = 2;
45
46
  export const WAKE_GIVE_UP_MS = 3 * MIN;
46
47
 
47
48
  // The checks the tick evaluates: piece-dup is raised where a piece starts,
48
- // bg-spin by the process step (spin-tick.ts).
49
+ // bg-spin and port-wait by the process steps (spin-tick.ts, port-wait-tick.ts).
49
50
  export const TICK_CHECKS: readonly CheckId[] = CHECKS.filter(
50
- (c) => c !== "piece-dup" && c !== "bg-spin"
51
+ (c) => c !== "piece-dup" && c !== "bg-spin" && c !== "port-wait"
51
52
  );
52
53
 
53
54
  export interface TickDeps {
@@ -58,6 +59,8 @@ export interface TickDeps {
58
59
  env: EnvDeps;
59
60
  // Stops background jobs that spin (spin-tick.ts).
60
61
  spin: (now: number) => Promise<void>;
62
+ // Tells agents that wait on a port someone else holds (port-wait-tick.ts).
63
+ portWait: (now: number, c: HealthConfig) => Promise<void>;
61
64
  wake: typeof wake;
62
65
  }
63
66
 
@@ -68,6 +71,7 @@ export const realTick: TickDeps = {
68
71
  route: realRoute,
69
72
  env: realEnv,
70
73
  spin: spinTick,
74
+ portWait: portWaitTick,
71
75
  wake,
72
76
  };
73
77
 
@@ -153,6 +157,9 @@ export function healthTick(
153
157
  void deps
154
158
  .spin(now)
155
159
  .catch((error) => console.error("[health] spin check:", error));
160
+ void deps
161
+ .portWait(now, c)
162
+ .catch((error) => console.error("[health] port-wait check:", error));
156
163
  void envTick(now, c, deps.env).catch((error) =>
157
164
  console.error("[health] environment failures:", error)
158
165
  );
@@ -15,6 +15,7 @@ export const CHECKS = [
15
15
  "queue-held",
16
16
  "bg-quiet",
17
17
  "bg-spin",
18
+ "port-wait",
18
19
  "piece-dup",
19
20
  "restarts",
20
21
  "ci-slow",
@@ -28,6 +29,7 @@ export const AREA_OF: Record<CheckId, Area> = {
28
29
  "queue-held": "scheduler",
29
30
  "bg-quiet": "tasks",
30
31
  "bg-spin": "tasks",
32
+ "port-wait": "tasks",
31
33
  "piece-dup": "pipeline",
32
34
  restarts: "pipeline",
33
35
  "ci-slow": "pipeline",
@@ -0,0 +1,46 @@
1
+ import type { RunUsage } from "../usage/check-runs";
2
+ import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
3
+ import { checkout, removeCheckout } from "../orchestrator/repo";
4
+ import {
5
+ ANALYSIS_SCHEMA,
6
+ ANALYST_SYSTEM,
7
+ analystPrompt,
8
+ toAnalysis,
9
+ type AnalystInput,
10
+ } from "./analyst-prompt";
11
+ import type { Analysis } from "./types";
12
+
13
+ // The model role the analyst runs on (BR02-17's "analyze"); the role's
14
+ // model is Settings' and the workspace's to pick, never the task's.
15
+ export const ANALYST_ROLE = "analyze" as const;
16
+
17
+ // One stateless run on a throwaway checkout of the snapshot: Read, Grep and
18
+ // Glob inside it, nothing else. Its cost comes back with the analysis.
19
+ export async function analyse(input: {
20
+ repo: string;
21
+ sha: string;
22
+ workspaceId: string | null;
23
+ prompt: AnalystInput;
24
+ claude?: ClaudeRunner;
25
+ }): Promise<{ analysis: Analysis; costUsd: number }> {
26
+ const dir = await checkout(input.repo, input.sha);
27
+ let cost = 0;
28
+ try {
29
+ const answer = await (input.claude ?? runClaude)({
30
+ cwd: dir,
31
+ system: ANALYST_SYSTEM,
32
+ prompt: analystPrompt(input.prompt),
33
+ schema: ANALYSIS_SCHEMA,
34
+ tools: ["Read", "Grep", "Glob"],
35
+ allow: [`Read(/${dir}/**)`, `Grep(/${dir}/**)`, `Glob(/${dir}/**)`],
36
+ onUsage: (u: RunUsage) => {
37
+ cost += u.costUsd;
38
+ },
39
+ role: ANALYST_ROLE,
40
+ workspaceId: input.workspaceId,
41
+ });
42
+ return { analysis: toAnalysis(answer), costUsd: cost };
43
+ } finally {
44
+ await removeCheckout(input.repo, dir);
45
+ }
46
+ }