@flame0510/project-aether 1.1.13 → 1.1.15

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.
@@ -1,4 +1,19 @@
1
- import { execFileSync } from 'child_process';
1
+ import { execFileSync, execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { dockerExecNoFail, mapWithConcurrency } from '@/lib/docker-exec';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ /**
8
+ * How many agents to query at once.
9
+ *
10
+ * Each one costs a `docker exec` plus an OpenClaw CLI boot — ~2.5-3s measured,
11
+ * and the route used to pay them in sequence: 19.2s on a seven-agent host, with
12
+ * the event loop blocked throughout. Running them concurrently makes the total
13
+ * the slowest agent rather than the sum. The cap is here because every one of
14
+ * these is a process, and the Docker daemon serialises past a point regardless.
15
+ */
16
+ const AGENT_QUERY_CONCURRENCY = 6;
2
17
 
3
18
  // ── Types ─────────────────────────────────────────────────────────────────
4
19
 
@@ -75,24 +90,26 @@ function extractScheduleExpr(job: OpenClawCronJob): string {
75
90
 
76
91
  // ── Discover agent containers ────────────────────────────────────────────
77
92
 
78
- function discoverAgentContainers(): AgentInfo[] {
93
+ async function discoverAgentContainers(): Promise<AgentInfo[]> {
79
94
  try {
80
- const namesRaw = execFileSync('docker', [
81
- 'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Names}}'
82
- ], { encoding: 'utf8', timeout: 5000 }).trim();
83
- if (!namesRaw) return [];
84
-
85
- return namesRaw.split('\n').map(name => {
86
- const containerName = name.trim();
87
- if (!containerName) return null;
88
- let agentId = containerName;
89
- try {
90
- agentId = execFileSync('docker', [
91
- 'inspect', containerName, '--format', '{{index .Config.Labels "AGENT_ID"}}'
92
- ], { encoding: 'utf8', timeout: 5000 }).trim();
93
- } catch { /* fallback */ }
94
- return { containerName, agentId: agentId || containerName };
95
- }).filter(Boolean) as AgentInfo[];
95
+ // One `docker ps` for everything. This used to run an extra `docker inspect`
96
+ // per container just to read the AGENT_ID label — but `docker ps` prints
97
+ // labels itself, so that was N+1 process spawns (~260ms each) for data the
98
+ // first call already had.
99
+ const raw = await execFileAsync('docker', [
100
+ 'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}',
101
+ ], { encoding: 'utf8', timeout: 5000 });
102
+
103
+ return raw.stdout.trim().split('\n')
104
+ .map((line) => {
105
+ const [agentId, containerName] = line.split('\t');
106
+ if (!containerName?.trim()) return null;
107
+ return {
108
+ containerName: containerName.trim(),
109
+ agentId: agentId?.trim() || containerName.trim(),
110
+ };
111
+ })
112
+ .filter(Boolean) as AgentInfo[];
96
113
  } catch {
97
114
  return [];
98
115
  }
@@ -113,34 +130,38 @@ function parseCronListOutput(raw: string): OpenClawCronJob[] | null {
113
130
  }
114
131
  }
115
132
 
116
- function listJobsOnHost(): OpenClawCronJob[] | null {
133
+ async function listJobsOnHost(): Promise<OpenClawCronJob[] | null> {
117
134
  try {
118
- const raw = execFileSync('openclaw', ['cron', 'list', '--json', '--all'], {
135
+ // Not a container command — this is the openclaw CLI on the host itself,
136
+ // so it goes through execFile directly rather than the docker-exec helper.
137
+ const raw = await execFileAsync('openclaw', ['cron', 'list', '--json', '--all'], {
119
138
  encoding: 'utf8', timeout: 15000,
120
139
  });
121
- return parseCronListOutput(raw);
140
+ return parseCronListOutput(raw.stdout);
122
141
  } catch {
123
142
  return null;
124
143
  }
125
144
  }
126
145
 
127
- function listJobsInContainer(containerName: string): OpenClawCronJob[] | null {
146
+ async function listJobsInContainer(containerName: string): Promise<OpenClawCronJob[] | null> {
128
147
  // Try both common agent gateway ports. Agents default to 3000 (OpenClaw
129
148
  // gateway --bind lan --port 3000) but may also run on 18789 (default).
149
+ // Kept sequential on purpose: the second is a fallback for the first, so
150
+ // firing both would double the load to discard one answer.
130
151
  const gatewayUrls = [
131
152
  'http://127.0.0.1:3000',
132
153
  'http://127.0.0.1:18789',
133
154
  ];
134
155
 
135
156
  for (const gwUrl of gatewayUrls) {
136
- try {
137
- const raw = execFileSync('docker', [
138
- 'exec', '-e', `OPENCLAW_GATEWAY_URL=${gwUrl}`,
139
- containerName, 'openclaw', 'cron', 'list', '--json', '--all',
140
- ], { encoding: 'utf8', timeout: 15000 });
141
- const parsed = parseCronListOutput(raw);
142
- if (parsed) return parsed;
143
- } catch { /* try next gateway port */ }
157
+ const raw = await dockerExecNoFail(
158
+ containerName,
159
+ ['openclaw', 'cron', 'list', '--json', '--all'],
160
+ { env: { OPENCLAW_GATEWAY_URL: gwUrl }, timeoutMs: 15000 },
161
+ );
162
+ if (!raw) continue;
163
+ const parsed = parseCronListOutput(raw);
164
+ if (parsed) return parsed;
144
165
  }
145
166
 
146
167
  return null;
@@ -180,12 +201,20 @@ function toggleInContainer(containerName: string, jobId: string, enabled: boolea
180
201
 
181
202
  // ── List: host gateway + per-container cron ─────────────────────────────
182
203
 
183
- export function listOpenClawCronJobs(): OpenClawCronListResult {
204
+ export async function listOpenClawCronJobs(): Promise<OpenClawCronListResult> {
184
205
  const seen = new Set<string>();
185
206
  const jobs: OpenClawCronJob[] = [];
186
207
 
208
+ // The host query and the agent discovery don't depend on each other, so they
209
+ // overlap. Everything below is ordered deterministically afterwards — host
210
+ // jobs first, then agents in discovery order — so the response doesn't shuffle
211
+ // between polls just because a container answered sooner this time.
212
+ const [hostJobs, agents] = await Promise.all([
213
+ listJobsOnHost(),
214
+ discoverAgentContainers(),
215
+ ]);
216
+
187
217
  // 1. Host gateway cron jobs
188
- const hostJobs = listJobsOnHost();
189
218
  if (hostJobs) {
190
219
  for (const job of hostJobs) {
191
220
  job.scheduleExpr = extractScheduleExpr(job);
@@ -195,22 +224,26 @@ export function listOpenClawCronJobs(): OpenClawCronListResult {
195
224
  }
196
225
  }
197
226
 
198
- // 2. Agent container cron jobs
199
- const agents = discoverAgentContainers();
200
- for (const agent of agents) {
201
- const containerJobs = listJobsInContainer(agent.containerName);
202
- if (containerJobs) {
203
- for (const job of containerJobs) {
204
- job.scheduleExpr = extractScheduleExpr(job);
205
- job.source = 'container';
206
- job.containerName = agent.containerName;
207
- if (!job.agentId) job.agentId = agent.agentId;
208
-
209
- if (job.id && seen.has(job.id)) continue;
210
-
211
- jobs.push(job);
212
- if (job.id) seen.add(job.id);
213
- }
227
+ // 2. Agent container cron jobs — queried concurrently, merged in order.
228
+ const perAgent = await mapWithConcurrency(
229
+ agents,
230
+ AGENT_QUERY_CONCURRENCY,
231
+ (agent) => listJobsInContainer(agent.containerName),
232
+ );
233
+
234
+ for (const [i, containerJobs] of perAgent.entries()) {
235
+ if (!containerJobs) continue;
236
+ const agent = agents[i];
237
+ for (const job of containerJobs) {
238
+ job.scheduleExpr = extractScheduleExpr(job);
239
+ job.source = 'container';
240
+ job.containerName = agent.containerName;
241
+ if (!job.agentId) job.agentId = agent.agentId;
242
+
243
+ if (job.id && seen.has(job.id)) continue;
244
+
245
+ jobs.push(job);
246
+ if (job.id) seen.add(job.id);
214
247
  }
215
248
  }
216
249
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flame0510/project-aether",
3
- "version": "1.1.13",
3
+ "version": "1.1.15",
4
4
  "description": "Rev4a — Revolution for Agents. OpenClaw agent fleet orchestrator.",
5
5
  "keywords": [
6
6
  "openclaw",