@flame0510/project-aether 1.4.0 → 1.4.1

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.
@@ -2,6 +2,8 @@ import { execSync } from 'child_process';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { DB_FILE, AGENTS_TOKEN_FILE, templatesDir } from '@/lib/rev4a-paths';
5
+ import { REV4A_PORT } from '@/lib/agent-ports';
6
+ import { getUsedHostPorts } from '@/lib/agent-ports-server';
5
7
  import { loadOfferedModels } from '@/lib/model-catalogue';
6
8
  import CreatePageClient from './PageClient';
7
9
 
@@ -45,19 +47,21 @@ async function getModels(): Promise<ModelInfo[]> {
45
47
  async function getUsedNames(): Promise<string[]> {
46
48
  try {
47
49
  const raw = execSync(
48
- `docker ps --filter "label=AGENT_ID" --format '{{.Names}}'`,
50
+ `docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}'`,
49
51
  { encoding: 'utf-8', timeout: 5000 },
50
52
  ).trim();
51
53
  if (!raw) return [];
52
54
 
53
- // Read AGENT_NAME env from each container (display name)
55
+ // Read AGENT_NAME from each container's inspect: a stopped agent still owns its
56
+ // display name, and `docker exec` cannot reach a stopped container.
54
57
  const names: string[] = [];
55
58
  for (const name of raw.split('\n').filter(Boolean)) {
56
59
  try {
57
- const displayName = execSync(
58
- `docker exec ${name} sh -c 'echo $AGENT_NAME'`,
60
+ const env = execSync(
61
+ `docker inspect ${name} --format '{{range .Config.Env}}{{println .}}{{end}}'`,
59
62
  { encoding: 'utf-8', timeout: 3000 },
60
- ).trim();
63
+ );
64
+ const displayName = env.match(/^AGENT_NAME=(.*)$/m)?.[1]?.trim();
61
65
  if (displayName) names.push(displayName);
62
66
  } catch { /* skip */ }
63
67
  }
@@ -68,29 +72,11 @@ async function getUsedNames(): Promise<string[]> {
68
72
  }
69
73
 
70
74
  async function getUsedPorts(): Promise<number[]> {
71
- try {
72
- const raw = execSync(
73
- `docker ps --format '{{.Ports}}'`,
74
- { encoding: 'utf-8', timeout: 5000 },
75
- ).trim();
76
- const ports: number[] = [];
77
- if (raw) {
78
- for (const line of raw.split('\n')) {
79
- const matches = line.match(/(\d+)->/g);
80
- if (matches) {
81
- matches.forEach((m) => {
82
- const p = parseInt(m.replace('->', ''), 10);
83
- if (!isNaN(p)) ports.push(p);
84
- });
85
- }
86
- }
87
- }
88
- // Add Rev4a port as reserved so the client won't suggest it
89
- ports.push(3740);
90
- return [...new Set(ports)];
91
- } catch {
92
- return [3740];
93
- }
75
+ // One source for port occupancy (lib/agent-ports-server.ts): it reads every
76
+ // container's bindings, so the block of a stopped agent is offered to nobody.
77
+ const ports = await getUsedHostPorts();
78
+ ports.add(REV4A_PORT);
79
+ return [...ports];
94
80
  }
95
81
 
96
82
  export default async function CreateAgentPage() {
@@ -123,25 +123,27 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
123
123
  );
124
124
  }
125
125
 
126
- // Check if name is already in use by a running container
126
+ // Check if name is already in use by a container, running or not: a stopped agent
127
+ // keeps its name and its port block, so both would clash on start.
127
128
  const existing = execSync(
128
- `docker ps --filter "label=AGENT_ID=${containerName}" --format '{{.Names}}'`,
129
+ `docker ps -a --filter "label=AGENT_ID=${containerName}" --format '{{.Names}}'`,
129
130
  { encoding: 'utf-8', timeout: 5000 },
130
131
  ).trim();
131
132
  if (existing) {
132
133
  return NextResponse.json(
133
- { success: false, error: `Agent '${displayName}' is already running (container: ${existing})` },
134
+ { success: false, error: `Agent '${displayName}' already exists (container: ${existing})` },
134
135
  { status: 409 },
135
136
  );
136
137
  }
137
138
 
138
- // Resolve port range
139
+ // Resolve port range. One lookup, shared by both paths below.
140
+ const usedPorts = await getUsedHostPorts();
139
141
  let isBlock = false;
140
142
  let portStart: number = 0;
141
143
  let portEnd: number = 0;
142
144
 
143
145
  if (portInput) {
144
- const v = validatePortInput(portInput, getUsedHostPorts());
146
+ const v = validatePortInput(portInput, usedPorts);
145
147
  if (!v.valid) {
146
148
  return NextResponse.json(
147
149
  { success: false, error: v.error },
@@ -152,7 +154,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
152
154
  portStart = v.start;
153
155
  portEnd = v.end;
154
156
  } else {
155
- portStart = findAvailablePortBlock(getUsedHostPorts());
157
+ portStart = findAvailablePortBlock(usedPorts);
156
158
  portEnd = portStart + DEFAULT_BLOCK_SIZE - 1;
157
159
  isBlock = true;
158
160
  }
@@ -15,7 +15,8 @@ export const dynamic = 'force-dynamic';
15
15
  * GET /api/version
16
16
  *
17
17
  * Returns the installed Rev4a version from package.json.
18
- * No auth required.
18
+ *
19
+ * Auth: the rev4a_token cookie or Authorization: Bearer <REV4A_TOKEN>.
19
20
  */
20
21
  export async function GET(request: NextRequest): Promise<NextResponse> {
21
22
  const denied = await requireAuthJWT(request); if (denied) return denied as any;
@@ -170,13 +170,25 @@ The central container, running the Next.js dashboard + orchestration API.
170
170
  `prerecreate` archives are pruned to the newest two. The same `recreateAgentContainer()`
171
171
  serves the edit route (`PATCH` → 202, no backup) and the Update action.
172
172
  `agentBusyReason()` (`lib/agent-busy.ts`) covers it for every route that changes an agent.
173
- - **Host ports** are shared between creation and the edit flow by `lib/agent-ports.ts`
174
- (pure: `validatePortInput`, `findAvailablePortBlock`, `portMappingArgs`) and
175
- `lib/agent-ports-server.ts` (`getUsedHostPorts()`, the Docker lookup, server-only since
176
- client components cannot import `child_process`). One block maps to the container's
177
- gateway port 3000 (`3700-3709` → `3700-3709:3000-3009`); both flows produce the same
178
- validation messages, and the edit excludes the agent's own ports so its block never
179
- conflicts with itself.
173
+ - **Host ports** have one source of truth: `lib/agent-ports.ts` holds the pure rules
174
+ (`validatePortInput`, `findAvailablePortBlock`, `portMappingArgs`,
175
+ `hostPortsFromBindings`, `hostPortsFromArgs`) and `lib/agent-ports-server.ts` answers
176
+ which ports are taken (`getHostPortHolders()`, `getUsedHostPorts()`; server-only, it
177
+ talks to the Docker socket). Creation, the create form, the edit flow and every
178
+ container rebuild (update, recreate, rollback) read their numbers from there. One block
179
+ maps to the container's gateway port 3000 (`3700-3709` → `3700-3709:3000-3009`); all
180
+ flows produce the same validation messages, and a flow rebuilding a container excludes
181
+ that container's own ports, so its block never conflicts with itself.
182
+ **A stopped container still holds its block**, but neither `docker ps -a --format
183
+ '{{.Ports}}'` nor the Docker API's container list reports its ports (verified on Docker
184
+ 29: both return nothing). The lookup therefore reads each container's
185
+ `HostConfig.PortBindings` — the only source covering the stopped ones. Without that, a
186
+ new agent could be handed the block of a stopped agent, and the clash only surfaced
187
+ later, when that agent was started again (`Bind for 0.0.0.0:3730 failed: port is
188
+ already allocated`), after the container it was replacing had been removed.
189
+ `recreateAgentContainer()` refuses such a clash **before** removing anything, naming the
190
+ container that holds each port, so the agent stays up and the operator changes the range
191
+ from the Edit panel; a failed `docker run` no longer leaves a container behind.
180
192
  - **Agent edits** are `lib/agent-edit.ts`, with their state in the `agent_edits` table
181
193
  (`lib/agent-edit-state.ts`): the display name (`AGENT_NAME`) and/or the port range are
182
194
  applied by rebuilding the container on the image it already runs — **no backup**, the
@@ -795,7 +795,7 @@ Create a new agent container from a template.
795
795
  |---|---|---|
796
796
  | `name` | yes | Display name (`AGENT_NAME`); the container name and `AGENT_ID` are generated (`agent_<hex>`) |
797
797
  | `template` | yes | Template name (directory in `agent-templates/`) |
798
- | `portRange` | no | Optional port range (e.g. `3700-3709`) or single port (e.g. `3700`). Default: auto-assigned 10-port block |
798
+ | `portRange` | no | Optional port range (e.g. `3700-3709`) or single port (e.g. `3700`). Default: auto-assigned 10-port block, chosen by `findAvailablePortBlock()` |
799
799
  | `model` | no | Primary model id. Must be offered by this deployment (in the catalogue, enabled, provider has a key), in any form the proxy accepts; otherwise `400`. Defaults to `rev4a/deepseek/deepseek-flash`, validated the same way |
800
800
  | `fallbacks` | no | Array of fallback model IDs |
801
801
 
@@ -804,6 +804,9 @@ Create a new agent container from a template.
804
804
  - Single port: `-p <port>:3000` — maps a single host port to container port 3000
805
805
  - Control UI: the wizard's Open button goes through `GET /api/agents/[id]/open-control-ui`, like the Agents page
806
806
  - Port 3740 is reserved for Rev4a
807
+ - A port held by any container, running or stopped, is refused (`400`): the shared lookup
808
+ reads every container's bindings (`lib/agent-ports-server.ts`), because a stopped
809
+ container keeps its allocation while `docker ps -a` and the API's list report none
807
810
  - Image: `openclaw-agent-base:<version>`, the newest supported OpenClaw version downloaded here; `409` when none is
808
811
 
809
812
  **Response:**
@@ -886,12 +889,23 @@ touch the agent answers `409`.
886
889
 
887
890
  `portRange` is `"<start>"` or `"<start>-<end>"`, mapped onto the gateway port 3000
888
891
  (`3700-3709` → `3700-3709:3000-3009`). It is validated with the same rules and messages as
889
- agent creation (`lib/agent-ports.ts`): a range that holds Rev4a's port `3740`, an invalid
890
- format, or a port already published by **another** agent answers `400`
892
+ agent creation (`lib/agent-ports.ts`) against the shared lookup
893
+ (`getUsedHostPorts()`, `lib/agent-ports-server.ts`): a range that holds Rev4a's port
894
+ `3740`, an invalid format, or a port already held by **another** container answers `400`
891
895
  (`Ports already in use: 3711, 3712`) before anything is rebuilt. The agent's own published
892
896
  ports are excluded, so keeping or shifting its block is not a conflict with itself.
897
+ A port held by a **stopped** container counts as in use: the lookup reads every
898
+ container's `HostConfig.PortBindings`, because both `docker ps -a --format '{{.Ports}}'`
899
+ and the API's container list report nothing for a container that is not running.
893
900
  At least one field is required; an empty `displayName` is rejected.
894
901
 
902
+ The rebuild itself (`recreateAgentContainer()`) re-checks the ports it is about to
903
+ publish against every other container and refuses **before removing anything** when one
904
+ is taken, naming the holder: `Port 3700 is already published by agent_24a68ac9 (Drill
905
+ Wake). Change this agent's port range in the Edit panel, then retry: nothing was
906
+ changed.` The row lands in `failed` with that message and the agent keeps running. The
907
+ same guard protects Update and Rollback, whose rows record the same text.
908
+
895
909
  **Response (202):**
896
910
  ```json
897
911
  { "started": true, "id": 4 }
@@ -2102,16 +2116,16 @@ Update audio or timezone config in `openclaw.json`.
2102
2116
  ## Version & WebSocket
2103
2117
 
2104
2118
  ### `GET /api/version`
2105
- Return the installed OpenClaw CLI version.
2119
+ Return the installed Rev4a version, read from `package.json`.
2106
2120
 
2107
- **Auth:** browser cookie
2121
+ **Auth:** `rev4a_token` cookie or `Authorization: Bearer <REV4A_TOKEN>`
2108
2122
 
2109
2123
  **Response:**
2110
2124
  ```json
2111
- { "version": "openclaw x.y.z" }
2125
+ { "version": "1.4.0" }
2112
2126
  ```
2113
2127
 
2114
- **Fallback:** `{ "version": "unknown" }` if the command fails.
2128
+ **Fallback:** `{ "version": "0.0.0" }` if `package.json` cannot be read.
2115
2129
 
2116
2130
  ### Terminal WebSocket
2117
2131
 
package/lib/agent-edit.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  * cannot start on top of the first (lib/agent-busy.ts answers 409 while one runs).
9
9
  */
10
10
  import { isValidAgentId } from '@/lib/container';
11
- import { portMappingArgs, validatePortInput } from '@/lib/agent-ports';
11
+ import { hostPortsFromBindings, portMappingArgs, validatePortInput } from '@/lib/agent-ports';
12
12
  import { getUsedHostPorts } from '@/lib/agent-ports-server';
13
- import { resolveRecreateImage, runDocker } from '@/lib/agent-images';
14
- import { inspectAgentContainer, recreateAgentContainer } from '@/lib/agent-recreate';
13
+ import { resolveRecreateImage } from '@/lib/agent-images';
14
+ import { inspectAgentContainer, recreateAgentContainer, startAndVerifyContainer } from '@/lib/agent-recreate';
15
15
  import { agentBusyReason } from '@/lib/agent-busy';
16
16
  import { waitForGatewayReady } from '@/lib/agent-readiness';
17
17
  import { applyRuntimeConfig } from '@/lib/agent-setup';
@@ -29,22 +29,14 @@ export class EditRefusedError extends Error {}
29
29
  * with itself — while a conflict with another agent still fails **before** the container
30
30
  * is removed.
31
31
  */
32
- function parsePortArgs(portRange: string, ownPorts: number[]): string[] {
33
- const used = getUsedHostPorts();
32
+ async function parsePortArgs(portRange: string, ownPorts: number[]): Promise<string[]> {
33
+ const used = await getUsedHostPorts();
34
34
  for (const p of ownPorts) used.delete(p);
35
35
  const result = validatePortInput(portRange, used);
36
36
  if (!result.valid) throw new EditRefusedError(result.error);
37
37
  return portMappingArgs(result.start, result.end);
38
38
  }
39
39
 
40
- /** The agent's own published host ports, from its container inspect. */
41
- function ownHostPorts(container: { HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> } }): number[] {
42
- return Object.values(container.HostConfig?.PortBindings ?? {})
43
- .flatMap((bindings) => bindings ?? [])
44
- .map((b) => Number(b?.HostPort))
45
- .filter((n) => Number.isInteger(n));
46
- }
47
-
48
40
  /** Edit jobs running in this process, so a second start for the same agent is refused. */
49
41
  const running = new Set<string>();
50
42
 
@@ -74,7 +66,7 @@ export async function startAgentEdit(agentId: string, opts: EditOptions): Promis
74
66
  if (!container) throw new EditRefusedError(`No container found with AGENT_ID '${agentId}'`);
75
67
  // A bad range is refused before any state is written, and before the container is
76
68
  // removed — the only failure mode that could leave the agent without one.
77
- const portArgs = opts.portRange ? parsePortArgs(opts.portRange, ownHostPorts(container)) : undefined;
69
+ const portArgs = opts.portRange ? await parsePortArgs(opts.portRange, hostPortsFromBindings(container)) : undefined;
78
70
 
79
71
  const id = insertEdit(agentId, displayName ?? null, portRange);
80
72
  void runEdit(id, agentId, container, displayName ?? null, portArgs).finally(() => running.delete(agentId));
@@ -149,9 +141,9 @@ export async function recoverInterruptedEdits(): Promise<number> {
149
141
  for (const agentId of ids) {
150
142
  const container = await inspectAgentContainer(agentId).catch(() => null);
151
143
  const name = container?.Name?.replace(/^\//, '');
152
- if (name && !container?.State?.Running) {
153
- await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
154
- }
144
+ if (!name || container?.State?.Running) continue;
145
+ const started = await startAndVerifyContainer(agentId, name);
146
+ if (!started.ok) console.warn(`[agent:edit] ${agentId} did not come back after an interrupted edit: ${started.error}`);
155
147
  }
156
148
  return interrupted;
157
149
  }
@@ -1,27 +1,78 @@
1
1
  /**
2
- * Server-side host-port lookup for lib/agent-ports.ts. Split out because it uses
3
- * `child_process`, which client components cannot import.
2
+ * Server-side host-port lookup for lib/agent-ports.ts. Split out because it uses the
3
+ * Docker socket, which client components cannot reach.
4
+ *
5
+ * This is the only place that answers "which host ports are taken": agent creation,
6
+ * the create form, the edit (rename/ports) flow and every container rebuild (update,
7
+ * recreate, rollback) all read their numbers from here.
8
+ *
9
+ * The numbers come from each container's `HostConfig.PortBindings`, the only source
10
+ * that also covers containers that are not running. Neither
11
+ * `docker ps -a --format '{{.Ports}}'` nor the API's container list reports ports for
12
+ * a stopped container (verified on Docker 29: both return nothing), while the
13
+ * allocation is still held — starting that container again fails with "port is
14
+ * already allocated". Reading the bindings is what stops a new agent from being given
15
+ * the block of a stopped one.
4
16
  */
5
- import { execSync } from 'child_process';
17
+ import { dockerFetch } from '@/lib/docker-socket';
18
+
19
+ export interface HostPortHolder {
20
+ /** Container name; agent containers are named after their id. */
21
+ name: string;
22
+ /** The container's `AGENT_NAME`, the name the panel shows; null for non-agent containers. */
23
+ displayName: string | null;
24
+ }
25
+
26
+ interface ContainerInspect {
27
+ Id?: string;
28
+ Name?: string;
29
+ Config?: { Env?: string[] };
30
+ HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> };
31
+ }
6
32
 
7
33
  /**
8
- * Host ports published by any container, running or not: a stopped container keeps its
9
- * port allocations, so `docker run -p` on them fails just the same.
34
+ * Host ports held by containers, running or stopped, each mapped to the container that
35
+ * holds it. `excludeContainerId` drops one container from the result: the block of the
36
+ * container a rebuild is about to replace is not a conflict with itself.
10
37
  *
11
- * Parsed in JS rather than piping through grep: the old `grep -oP '\d+(?=->)'` form lost
12
- * its backslash inside a template literal, matched nothing, and offered port 3700 to
13
- * every agent; `grep -P` is also GNU-only and absent on macOS.
38
+ * Best-effort: an unreachable daemon yields an empty map, as the previous shell-based
39
+ * lookup did, rather than failing the caller.
14
40
  */
15
- export function getUsedHostPorts(): Set<number> {
41
+ export async function getHostPortHolders(opts: { excludeContainerId?: string } = {}): Promise<Map<number, HostPortHolder>> {
42
+ const holders = new Map<number, HostPortHolder>();
43
+ let list: { Id?: string }[];
16
44
  try {
17
- const raw = execSync(`docker ps -a --format '{{.Ports}}'`, { encoding: 'utf-8', timeout: 3000 });
18
- const used = new Set<number>();
19
- for (const [, port] of raw.matchAll(/(\d+)->/g)) {
20
- const n = Number(port);
21
- if (Number.isInteger(n)) used.add(n);
22
- }
23
- return used;
45
+ list = await dockerFetch<{ Id?: string }[]>('GET', '/containers/json?all=true');
24
46
  } catch {
25
- return new Set();
47
+ return holders;
26
48
  }
49
+ const ids = (Array.isArray(list) ? list : [])
50
+ .map((c) => c.Id)
51
+ .filter((id): id is string => typeof id === 'string' && id !== opts.excludeContainerId);
52
+ const infos = await Promise.all(
53
+ ids.map((id) => dockerFetch<ContainerInspect>('GET', `/containers/${id}/json`).catch(() => null)),
54
+ );
55
+ for (const info of infos) {
56
+ if (!info) continue;
57
+ const name = (info.Name ?? '').replace(/^\//, '') || (info.Id ?? '').slice(0, 12);
58
+ const agentName = (info.Config?.Env ?? []).find((entry) => entry.startsWith('AGENT_NAME='));
59
+ const holder: HostPortHolder = { name, displayName: agentName ? agentName.slice('AGENT_NAME='.length) : null };
60
+ for (const bindings of Object.values(info.HostConfig?.PortBindings ?? {})) {
61
+ for (const binding of bindings ?? []) {
62
+ const port = Number(binding?.HostPort);
63
+ if (Number.isInteger(port)) holders.set(port, holder);
64
+ }
65
+ }
66
+ }
67
+ return holders;
68
+ }
69
+
70
+ /** The port set the validators in lib/agent-ports.ts take. */
71
+ export async function getUsedHostPorts(opts: { excludeContainerId?: string } = {}): Promise<Set<number>> {
72
+ return new Set((await getHostPortHolders(opts)).keys());
73
+ }
74
+
75
+ /** How a holder reads in an error: `agent_24a68ac9 (Drill Wake)`, or the name alone. */
76
+ export function describeHolder(holder: HostPortHolder): string {
77
+ return holder.displayName ? `${holder.name} (${holder.displayName})` : holder.name;
27
78
  }
@@ -66,3 +66,26 @@ export function validatePortInput(input: string, usedPorts: ReadonlySet<number>
66
66
  export function portMappingArgs(start: number, end: number): string[] {
67
67
  return ['-p', end === start ? `${start}:3000` : `${start}-${end}:3000-${3000 + (end - start)}`];
68
68
  }
69
+
70
+ /** The host ports a container publishes, from its inspect (`HostConfig.PortBindings`). */
71
+ export function hostPortsFromBindings(container: {
72
+ HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> };
73
+ }): number[] {
74
+ return Object.values(container.HostConfig?.PortBindings ?? {})
75
+ .flatMap((bindings) => bindings ?? [])
76
+ .map((b) => Number(b?.HostPort))
77
+ .filter((n) => Number.isInteger(n));
78
+ }
79
+
80
+ /** The host ports a `docker run` argument list publishes: `3700:3000` or `3700-3709:3000-3009`. */
81
+ export function hostPortsFromArgs(portArgs: readonly string[]): number[] {
82
+ const ports: number[] = [];
83
+ for (let i = 0; i < portArgs.length - 1; i++) {
84
+ if (portArgs[i] !== '-p') continue;
85
+ const host = (portArgs[i + 1] ?? '').split(':')[0];
86
+ const [start, end] = host.split('-').map(Number);
87
+ if (!Number.isInteger(start)) continue;
88
+ for (let p = start; p <= (Number.isInteger(end) ? end : start); p++) ports.push(p);
89
+ }
90
+ return ports;
91
+ }
@@ -12,6 +12,8 @@
12
12
  */
13
13
  import { dockerFetch } from '@/lib/docker-socket';
14
14
  import { runDocker, resolveRecreateImage } from '@/lib/agent-images';
15
+ import { hostPortsFromArgs } from '@/lib/agent-ports';
16
+ import { getHostPortHolders, describeHolder, type HostPortHolder } from '@/lib/agent-ports-server';
15
17
  import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
16
18
  import { isValidAgentId } from '@/lib/container';
17
19
  import { BACKUP_VOLUME, coldBackupStatus, isColdBackupRunning, startColdBackup, waitForColdBackup } from '@/lib/cold-backup';
@@ -30,12 +32,13 @@ const RECREATE_READY_TIMEOUT_MS = 5 * 60 * 1000;
30
32
  const COLD_BACKUP_WAIT_TIMEOUT_MS = 30 * 60 * 1000;
31
33
 
32
34
  export interface AgentContainerInspect {
35
+ Id?: string;
33
36
  Name?: string;
34
37
  Image?: string;
35
38
  Config?: { Image?: string; Env?: string[]; Labels?: Record<string, string> };
36
39
  State?: { Running?: boolean; Status?: string };
37
40
  HostConfig?: { NetworkMode?: string; PortBindings?: Record<string, { HostPort?: string }[] | null> };
38
- NetworkSettings?: { Networks?: Record<string, unknown> };
41
+ NetworkSettings?: { Networks?: Record<string, unknown>; Ports?: Record<string, unknown> };
39
42
  }
40
43
 
41
44
  /** The container carrying `AGENT_ID=<agentId>`, inspected; null when there is none. */
@@ -48,6 +51,31 @@ export async function inspectAgentContainer(agentId: string): Promise<AgentConta
48
51
  return typeof info?.Id === 'string' ? info : null;
49
52
  }
50
53
 
54
+ /**
55
+ * Start the container and confirm it really came back. A container can report
56
+ * `running` while its network endpoint failed to attach — the state a failed port bind
57
+ * leaves behind — which would otherwise pass as a successful start.
58
+ */
59
+ export async function startAndVerifyContainer(agentId: string, name: string): Promise<{ ok: true } | { ok: false; error: string }> {
60
+ try {
61
+ await runDocker(['start', name], { timeoutMs: 60_000 });
62
+ } catch (e) {
63
+ return { ok: false, error: shortDockerError(e) };
64
+ }
65
+ const after = await inspectAgentContainer(agentId).catch(() => null);
66
+ if (!after?.State?.Running) return { ok: false, error: 'the container is not running' };
67
+ if (Object.keys(after.NetworkSettings?.Networks ?? {}).length === 0) {
68
+ return { ok: false, error: 'the container came up without a network (one of its ports is taken by another container)' };
69
+ }
70
+ return { ok: true };
71
+ }
72
+
73
+ /** A docker failure carries a whole stack trace; the first line is the useful part. */
74
+ function shortDockerError(e: unknown): string {
75
+ const message = (e as Error)?.message ?? String(e);
76
+ return message.split('\n').map((l) => l.trim()).filter(Boolean)[0] ?? message;
77
+ }
78
+
51
79
  /**
52
80
  * Remove the container and run a new one on `image` with the same parameters. The new
53
81
  * container is named after the agent id, as the create route names it. `opts.env`
@@ -88,6 +116,23 @@ export async function recreateAgentContainer(
88
116
  return ['-p', `${hostPort}:${containerPort.replace(/\/.*$/, '')}${proto}`];
89
117
  });
90
118
 
119
+ // Refuse on a port clash before anything is removed: the old container is the
120
+ // fallback the caller restarts, so a recreate refused here leaves the agent up. A
121
+ // clash cannot be recovered from by retrying, so it must not reach `docker run`.
122
+ const wanted = hostPortsFromArgs(portArgs);
123
+ if (wanted.length && container.Id) {
124
+ const held = await getHostPortHolders({ excludeContainerId: container.Id }).catch(() => new Map<number, HostPortHolder>());
125
+ const conflicts = wanted.filter((p) => held.has(p));
126
+ if (conflicts.length) {
127
+ const holders = [...new Set(conflicts.map((p) => describeHolder(held.get(p)!)))];
128
+ const many = conflicts.length > 1;
129
+ throw new Error(
130
+ `${many ? 'Ports' : 'Port'} ${conflicts.join(', ')} ${many ? 'are' : 'is'} already published by ${holders.join(', ')}. `
131
+ + 'Change this agent\'s port range in the Edit panel, then retry: nothing was changed.',
132
+ );
133
+ }
134
+ }
135
+
91
136
  const oldName = (container.Name ?? '').replace(/^\//, '') || agentId;
92
137
  await runDocker(['rm', '-f', oldName], { timeoutMs: 60_000 });
93
138
 
@@ -115,7 +160,16 @@ export async function recreateAgentContainer(
115
160
  console.warn(`[agent:recreate] docker run failed for ${agentId}, retrying:`, (first as Error).message);
116
161
  await runDocker(['rm', '-f', agentId], { timeoutMs: 60_000 }).catch(() => {});
117
162
  const retryImage = await resolveRecreateImage(container).catch(() => image);
118
- await runDocker(runArgs(retryImage), { timeoutMs: 120_000, env });
163
+ try {
164
+ await runDocker(runArgs(retryImage), { timeoutMs: 120_000, env });
165
+ } catch (second) {
166
+ // `docker run` creates the container before its network step, so a failed start
167
+ // leaves a container behind — one that reports `running` with no network and
168
+ // would be picked up as if it were the agent. Remove it, so the state the caller
169
+ // reports matches reality.
170
+ await runDocker(['rm', '-f', agentId], { timeoutMs: 60_000 }).catch(() => {});
171
+ throw second;
172
+ }
119
173
  }
120
174
  }
121
175
 
@@ -267,9 +321,9 @@ async function finishInterruptedRecreate(agentId: string): Promise<void> {
267
321
  if (running) await waitForColdBackup(agentId).catch(() => null);
268
322
  const container = await inspectAgentContainer(agentId).catch(() => null);
269
323
  const name = container?.Name?.replace(/^\//, '');
270
- if (name && !container?.State?.Running) {
271
- await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
272
- }
324
+ if (!name || container?.State?.Running) return;
325
+ const started = await startAndVerifyContainer(agentId, name);
326
+ if (!started.ok) console.warn(`[agent:recreate] ${agentId} did not come back after an interrupted recreate: ${started.error}`);
273
327
  }
274
328
 
275
329
  // ── Prerecreate retention ────────────────────────────────────────────────────
@@ -10,7 +10,7 @@
10
10
  import { isValidAgentId } from '@/lib/container';
11
11
  import { runDocker } from '@/lib/agent-images';
12
12
  import { BACKUP_VOLUME } from '@/lib/cold-backup';
13
- import { inspectAgentContainer } from '@/lib/agent-recreate';
13
+ import { inspectAgentContainer, startAndVerifyContainer } from '@/lib/agent-recreate';
14
14
  import { activeRestoreAgentIds, insertRestore, isRestoreActive, latestRestore, markInterruptedRestores, updateRestoreRow, type AgentRestoreRow } from '@/lib/agent-restore-state';
15
15
  import { agentBusyReason } from '@/lib/agent-busy';
16
16
 
@@ -60,6 +60,7 @@ export async function startAgentRestore(agentId: string, file: string): Promise<
60
60
  async function runRestore(id: number, agentId: string, file: string): Promise<void> {
61
61
  const container = await inspectAgentContainer(agentId).catch(() => null);
62
62
  const name = container?.Name?.replace(/^\//, '');
63
+ let failure: string | null = null;
63
64
  try {
64
65
  if (name && container?.State?.Running) {
65
66
  await runDocker(['stop', '-t', '30', name], { timeoutMs: 45_000 }).catch(() => {});
@@ -77,16 +78,21 @@ async function runRestore(id: number, agentId: string, file: string): Promise<vo
77
78
  ],
78
79
  { timeoutMs: EXTRACT_TIMEOUT_MS, env: { FILE: file } },
79
80
  );
80
-
81
- updateRestoreRow(id, { status: 'done' });
82
81
  } catch (e) {
83
- updateRestoreRow(id, { status: 'failed', error: (e as Error)?.message ?? String(e) });
82
+ failure = (e as Error)?.message ?? String(e);
84
83
  } finally {
85
84
  // The container is started again even when the extract failed, so the agent never
86
- // stays down. A volume with no container (volume-only agent) has nothing to start.
85
+ // stays down. The row is only `done` when it really came back: a container can
86
+ // report running while its network never attached, which is not a usable agent.
87
87
  if (name) {
88
- await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
88
+ const started = await startAndVerifyContainer(agentId, name);
89
+ if (!started.ok) {
90
+ const note = `the container did not come back: ${started.error}`;
91
+ failure = failure ? `${failure}; ${note}` : `the volume was restored, but ${note}`;
92
+ }
89
93
  }
94
+ if (failure) updateRestoreRow(id, { status: 'failed', error: failure });
95
+ else updateRestoreRow(id, { status: 'done' });
90
96
  }
91
97
  }
92
98
 
@@ -127,9 +133,9 @@ export async function recoverInterruptedRestores(): Promise<number> {
127
133
  for (const agentId of ids) {
128
134
  const container = await inspectAgentContainer(agentId).catch(() => null);
129
135
  const name = container?.Name?.replace(/^\//, '');
130
- if (name && !container?.State?.Running) {
131
- await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
132
- }
136
+ if (!name || container?.State?.Running) continue;
137
+ const started = await startAndVerifyContainer(agentId, name);
138
+ if (!started.ok) console.warn(`[agent:restore] ${agentId} did not come back after an interrupted restore: ${started.error}`);
133
139
  }
134
140
  return interrupted;
135
141
  }
@@ -32,7 +32,7 @@ import {
32
32
  runDocker,
33
33
  } from '@/lib/agent-images';
34
34
  import { BACKUP_VOLUME, coldBackupStatus, isColdBackupRunning, startColdBackup, waitForColdBackup } from '@/lib/cold-backup';
35
- import { inspectAgentContainer, recreateAgentContainer, type AgentContainerInspect } from '@/lib/agent-recreate';
35
+ import { inspectAgentContainer, recreateAgentContainer, startAndVerifyContainer, type AgentContainerInspect } from '@/lib/agent-recreate';
36
36
  import { applyRuntimeConfig } from '@/lib/agent-setup';
37
37
  import { waitForGatewayReady } from '@/lib/agent-readiness';
38
38
  import { patchRev4aProvider } from '@/app/api/gateway/sync';
@@ -251,12 +251,20 @@ async function runUpdate(id: number, agentId: string, name: string, fromVersion:
251
251
  .catch((err: unknown) => console.warn('[agent:update] image cleanup failed:', (err as Error).message));
252
252
  } catch (e) {
253
253
  // A failure before the recreate leaves the old container stopped by the backup:
254
- // start it again, so a failed update never leaves the agent down.
254
+ // start it again, so a failed update never leaves the agent down. When it does not
255
+ // come back the failure text says so — a container that is "running" with no network
256
+ // is not an agent the operator can use.
255
257
  if (stopped) {
256
258
  const container = await inspectAgentContainer(agentId).catch(() => null);
257
259
  const containerName = container?.Name?.replace(/^\//, '');
258
260
  if (containerName && !container?.State?.Running) {
259
- await runDocker(['start', containerName], { timeoutMs: 60_000 }).catch(() => {});
261
+ const started = await startAndVerifyContainer(agentId, containerName);
262
+ if (!started.ok) {
263
+ const note = `the previous container did not come back: ${started.error}`;
264
+ const message = shortError(e);
265
+ updateRow(id, { status: 'failed', error: `${message}; ${note}` });
266
+ return;
267
+ }
260
268
  }
261
269
  }
262
270
  updateRow(id, { status: 'failed', error: shortError(e) });
@@ -285,9 +293,9 @@ export async function recoverInterruptedUpdates(): Promise<number> {
285
293
  for (const agentId of ids) {
286
294
  const container = await inspectAgentContainer(agentId).catch(() => null);
287
295
  const name = container?.Name?.replace(/^\//, '');
288
- if (name && !container?.State?.Running) {
289
- await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
290
- }
296
+ if (!name || container?.State?.Running) continue;
297
+ const started = await startAndVerifyContainer(agentId, name);
298
+ if (!started.ok) console.warn(`[agent:update] ${agentId} did not come back after an interrupted update: ${started.error}`);
291
299
  }
292
300
  return interrupted;
293
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flame0510/project-aether",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Rev4a — Revolution for Agents. OpenClaw agent fleet orchestrator.",
5
5
  "keywords": [
6
6
  "openclaw",