@saadnvd1/agent-os 0.3.728 → 0.3.733
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/app/api/bus/ports/route.test.ts +43 -0
- package/app/api/bus/ports/route.ts +35 -0
- package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
- package/app/api/sessions/[id]/export/turn/route.ts +33 -0
- package/components/Chat/Messages.tsx +2 -0
- package/components/Chat/TurnCopy.tsx +84 -0
- package/components/SessionList/GroupShelves.tsx +57 -22
- package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
- package/components/SessionList/SessionRow.tsx +12 -1
- package/components/SessionList/SidebarContextMenu.test.ts +21 -1
- package/components/SessionList/SidebarContextMenu.tsx +33 -9
- package/components/SessionList/index.tsx +11 -2
- package/components/SessionList/useGroupDrag.ts +83 -0
- package/components/Updates/UpdateActions.tsx +6 -0
- package/components/Updates/UpdateBanner.test.ts +1 -0
- package/data/exports/queries.ts +11 -0
- package/lib/agents/brief.ts +1 -1
- package/lib/claims/collectLocal.ts +3 -1
- package/lib/claims/overlap.ts +22 -2
- package/lib/export/markdown.ts +7 -1
- package/lib/export/turn.ts +48 -0
- package/lib/health/config.ts +2 -0
- package/lib/health/env/classes.ts +14 -1
- package/lib/health/port-wait-tick.ts +222 -0
- package/lib/health/port-wait.ts +160 -0
- package/lib/health/tick.ts +9 -2
- package/lib/health/types.ts +2 -0
- package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
- package/lib/ports/ensurePorts.ts +138 -0
- package/lib/ports/findHardcodedPorts.ts +116 -0
- package/lib/ports/index.ts +15 -0
- package/lib/ports/listListeners.ts +134 -0
- package/lib/ports/reassignPort.ts +45 -0
- package/lib/ports/types.ts +62 -0
- package/lib/ports/whoHolds.ts +155 -0
- package/lib/project-config/brief.ts +4 -2
- package/lib/project-config/session.ts +19 -3
- package/lib/sidebar/groupDrop.ts +36 -0
- package/lib/updates/banner.ts +3 -0
- package/lib/updates/run.ts +7 -2
- package/lib/updates/view.ts +2 -0
- package/lib/windows-ports.ts +36 -18
- package/package.json +1 -1
- package/scripts/redeploy +10 -1
- package/scripts/redeploy.test.ts +77 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type DragEvent } from "react";
|
|
2
|
+
import { useAssignGroup } from "@/data/session-groups";
|
|
3
|
+
import {
|
|
4
|
+
DRAG_TYPE,
|
|
5
|
+
dragging,
|
|
6
|
+
dropOutcome,
|
|
7
|
+
setDragging,
|
|
8
|
+
type DraggedSession,
|
|
9
|
+
type DropTarget,
|
|
10
|
+
} from "@/lib/sidebar/groupDrop";
|
|
11
|
+
|
|
12
|
+
// A session row you can pick up. Whole-row drag; the row's own click and
|
|
13
|
+
// menus are untouched, and touch keeps using Move to group.
|
|
14
|
+
export function useRowDrag(dragged: DraggedSession, enabled: boolean) {
|
|
15
|
+
return {
|
|
16
|
+
draggable: enabled,
|
|
17
|
+
onDragStart: (e: DragEvent) => {
|
|
18
|
+
setDragging(dragged);
|
|
19
|
+
e.dataTransfer.effectAllowed = "move";
|
|
20
|
+
e.dataTransfer.setData(DRAG_TYPE, dragged.id);
|
|
21
|
+
},
|
|
22
|
+
onDragEnd: () => setDragging(null),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EXPAND_AFTER_MS = 600;
|
|
27
|
+
|
|
28
|
+
// Where a dragged session can land. A group passes itself as the target; the
|
|
29
|
+
// list around the groups passes null, which takes the session out of its group.
|
|
30
|
+
export function useDropZone(
|
|
31
|
+
target: DropTarget | null,
|
|
32
|
+
// A folded group opens when a valid drag rests on it.
|
|
33
|
+
onRest?: () => void
|
|
34
|
+
) {
|
|
35
|
+
const assign = useAssignGroup();
|
|
36
|
+
const [over, setOver] = useState(false);
|
|
37
|
+
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
38
|
+
const stopRest = () => clearTimeout(timer.current);
|
|
39
|
+
useEffect(() => stopRest, []);
|
|
40
|
+
|
|
41
|
+
const outcome = () => {
|
|
42
|
+
const d = dragging();
|
|
43
|
+
return d ? { d, out: dropOutcome(d, target) } : null;
|
|
44
|
+
};
|
|
45
|
+
const leave = () => {
|
|
46
|
+
stopRest();
|
|
47
|
+
setOver(false);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
over,
|
|
52
|
+
props: {
|
|
53
|
+
onDragOver: (e: DragEvent) => {
|
|
54
|
+
const o = outcome();
|
|
55
|
+
if (!o?.out) return;
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
e.dataTransfer.dropEffect = "move";
|
|
59
|
+
if (target) {
|
|
60
|
+
setOver(true);
|
|
61
|
+
if (onRest && !timer.current)
|
|
62
|
+
timer.current = setTimeout(onRest, EXPAND_AFTER_MS);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
onDragLeave: (e: DragEvent) => {
|
|
66
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
|
67
|
+
leave();
|
|
68
|
+
timer.current = undefined;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
onDrop: (e: DragEvent) => {
|
|
72
|
+
const o = outcome();
|
|
73
|
+
leave();
|
|
74
|
+
timer.current = undefined;
|
|
75
|
+
if (!o?.out) return;
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
e.stopPropagation();
|
|
78
|
+
setDragging(null);
|
|
79
|
+
assign.mutate({ ids: [o.d.id], groupId: o.out.groupId });
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -50,6 +50,12 @@ export function UpdateActions({ view }: { view: UpdateView }) {
|
|
|
50
50
|
Cancel
|
|
51
51
|
</Button>
|
|
52
52
|
);
|
|
53
|
+
if (view.canUpdate.code === "no_restart")
|
|
54
|
+
return (
|
|
55
|
+
<Button size="sm" className="min-h-11 md:min-h-0" disabled>
|
|
56
|
+
Update now
|
|
57
|
+
</Button>
|
|
58
|
+
);
|
|
53
59
|
if (!view.canUpdate.ok) return null;
|
|
54
60
|
|
|
55
61
|
const start = (when: "now" | "idle") => {
|
package/data/exports/queries.ts
CHANGED
|
@@ -49,3 +49,14 @@ export async function fetchExport(
|
|
|
49
49
|
name: exportFileName(title, sel ? rangeSlug(sel) : "all", req.format),
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/** One turn as Markdown (secrets masked), and its last agent message. */
|
|
54
|
+
export async function fetchTurnCopy(
|
|
55
|
+
sessionId: string,
|
|
56
|
+
startId: string
|
|
57
|
+
): Promise<{ full: string; response: string }> {
|
|
58
|
+
const res = await post(`/api/sessions/${sessionId}/export/turn`, {
|
|
59
|
+
startId,
|
|
60
|
+
});
|
|
61
|
+
return res.ok ? res.json() : failure(res);
|
|
62
|
+
}
|
package/lib/agents/brief.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What every agent AgentOS starts is told about waiting; the note sent after
|
|
2
2
|
// AgentOS stops a spinning background job repeats it (lib/health/spin.ts).
|
|
3
3
|
export const WAIT_ADVICE =
|
|
4
|
-
"To wait for a long command, run it with run_in_background and end your turn; you're woken when it exits. Don't start a second job to poll for the first. Any wait loop must sleep on each pass and test a condition that can actually become true (pgrep for the exact process, a log line), never a fixed sleep.";
|
|
4
|
+
"To wait for a long command, run it with run_in_background and end your turn; you're woken when it exits. Don't start a second job to poll for the first. Any wait loop must sleep on each pass and test a condition that can actually become true (pgrep for the exact process, a log line), never a fixed sleep. Never wait for a port another process holds: `aos port` gives you a free one.";
|
|
5
5
|
|
|
6
6
|
// What every agent AgentOS starts is told about the network it's on.
|
|
7
7
|
export const BUS_BRIEF = `You are one of several coding agents running in AgentOS. Other sessions may be working on related projects at the same time, and you can talk to them with the \`aos\` command:
|
|
@@ -7,6 +7,7 @@ import { git } from "../git-exec";
|
|
|
7
7
|
import { repoKey } from "../git-remote";
|
|
8
8
|
import { IN_FLIGHT } from "../tasks/queue-rows";
|
|
9
9
|
import { expandHome } from "../tasks/session";
|
|
10
|
+
import { isNoise } from "./overlap";
|
|
10
11
|
import { MAX_FILES } from "./types";
|
|
11
12
|
import {
|
|
12
13
|
localLiveSessionIds,
|
|
@@ -47,7 +48,8 @@ export async function changedFiles(
|
|
|
47
48
|
git(dir, ["ls-files", "--others", "--exclude-standard", "-z"], OPTS),
|
|
48
49
|
]);
|
|
49
50
|
return [...new Set([...paths(committed), ...paths(untracked)])].filter(
|
|
50
|
-
(p) =>
|
|
51
|
+
(p) =>
|
|
52
|
+
!p.split("/").some((seg) => seg.endsWith(".aos-partial")) && !isNoise(p)
|
|
51
53
|
);
|
|
52
54
|
}
|
|
53
55
|
|
package/lib/claims/overlap.ts
CHANGED
|
@@ -7,8 +7,28 @@ import type { Claim, Overlap } from "./types";
|
|
|
7
7
|
const NOISE =
|
|
8
8
|
/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Gemfile\.lock|Cargo\.lock|go\.sum|poetry\.lock|uv\.lock|composer\.lock|CHANGELOG[^/]*|__snapshots__\/.*|migrations\.generated\.ts)$/i;
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// Build output, caches and OS files: directory segments (never name substrings).
|
|
11
|
+
const NOISE_DIRS = new Set([
|
|
12
|
+
"__pycache__",
|
|
13
|
+
"node_modules",
|
|
14
|
+
".next",
|
|
15
|
+
"dist",
|
|
16
|
+
"build",
|
|
17
|
+
"coverage",
|
|
18
|
+
".pytest_cache",
|
|
19
|
+
".mypy_cache",
|
|
20
|
+
".ruff_cache",
|
|
21
|
+
]);
|
|
22
|
+
const NOISE_FILE = /(\.(pyc|pyo|tsbuildinfo)|^\.DS_Store)$/;
|
|
23
|
+
|
|
24
|
+
export const isNoise = (path: string) => {
|
|
25
|
+
if (NOISE.test(path) || /(^|\/)__snapshots__\//.test(path)) return true;
|
|
26
|
+
const segs = path.split("/");
|
|
27
|
+
return (
|
|
28
|
+
segs.slice(0, -1).some((s) => NOISE_DIRS.has(s)) ||
|
|
29
|
+
NOISE_FILE.test(segs[segs.length - 1])
|
|
30
|
+
);
|
|
31
|
+
};
|
|
12
32
|
|
|
13
33
|
// A path this many claims touch is a shared index or router, not a signal.
|
|
14
34
|
export const HUB_CLAIMS = 4;
|
package/lib/export/markdown.ts
CHANGED
|
@@ -25,7 +25,10 @@ const quote = (s: string) =>
|
|
|
25
25
|
.map((l) => (l ? `> ${l}` : ">"))
|
|
26
26
|
.join("\n");
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
export const UNAVAILABLE = "[output unavailable]";
|
|
29
|
+
export const UNAVAILABLE_RUNNING = "[output unavailable: tool still running]";
|
|
30
|
+
|
|
31
|
+
export function itemMd(i: ExportItem): string {
|
|
29
32
|
const t = clock(i.at);
|
|
30
33
|
switch (i.kind) {
|
|
31
34
|
case "user": {
|
|
@@ -59,6 +62,9 @@ function itemMd(i: ExportItem): string {
|
|
|
59
62
|
);
|
|
60
63
|
if (i.diff?.patch) parts.push(fenced(i.diff.patch, "diff"));
|
|
61
64
|
if (i.output) parts.push(`Output:\n\n${fenced(i.output)}`);
|
|
65
|
+
else if (i.status === "running") parts.push(UNAVAILABLE_RUNNING);
|
|
66
|
+
else if (i.status === "stopped" || i.status === "error")
|
|
67
|
+
parts.push(UNAVAILABLE);
|
|
62
68
|
return parts.join("\n\n");
|
|
63
69
|
}
|
|
64
70
|
case "command_output":
|
|
@@ -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
|
+
}
|
package/lib/health/config.ts
CHANGED
|
@@ -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.`;
|