@oxgeneral/orch 0.3.3 → 1.0.0

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 (131) hide show
  1. package/dist/App-GJVTVGRU.js +6717 -0
  2. package/dist/agent-7ZJ3ZDJ7.js +183 -0
  3. package/dist/agent-shop-YN2BSLHM.js +2 -0
  4. package/dist/chunk-2C2TFQ7K.js +136 -0
  5. package/dist/chunk-45K2XID7.js +29 -0
  6. package/dist/chunk-4IFIOMCW.js +86 -0
  7. package/dist/chunk-7X2GI5OV.js +181 -0
  8. package/dist/{chunk-S3QYSBW4.js → chunk-C6XZ3FJT.js} +6 -3
  9. package/dist/chunk-C6XZ3FJT.js.map +1 -0
  10. package/dist/chunk-CHIP7O6V.js +83 -0
  11. package/dist/{chunk-BCPUTULS.js → chunk-DAVHOWGD.js} +188 -16
  12. package/dist/chunk-FRTKB575.js +87 -0
  13. package/dist/{chunk-BGHCY7WY.js → chunk-GBXUNDKN.js} +32 -13
  14. package/dist/chunk-GBXUNDKN.js.map +1 -0
  15. package/dist/chunk-HXYAZGLP.js +15 -0
  16. package/dist/chunk-I3SMISEF.js +29 -0
  17. package/dist/chunk-K6DMQERQ.js +89 -0
  18. package/dist/chunk-LV6GDBBI.js +297 -0
  19. package/dist/chunk-MGGSRXWJ.js +69 -0
  20. package/dist/chunk-P6ATSXGL.js +107 -0
  21. package/dist/chunk-PNE6LQRF.js +5 -0
  22. package/dist/chunk-U2VDNUZL.js +52 -0
  23. package/dist/chunk-VG4465AG.js +275 -0
  24. package/dist/chunk-VG4465AG.js.map +1 -0
  25. package/dist/{chunk-B4JQM4NU.js → chunk-VXS2CJFH.js} +119 -47
  26. package/dist/chunk-XJTJ2TJV.js +221 -0
  27. package/dist/{claude-INM52PTH.js → claude-WUJU5KIE.js} +6 -5
  28. package/dist/claude-WUJU5KIE.js.map +1 -0
  29. package/dist/claude-ZUEKJJ4X.js +5 -0
  30. package/dist/cli.js +199 -1
  31. package/dist/clipboard-service-RTDUUQQU.js +200 -0
  32. package/dist/codex-7IXXXG5U.js +123 -0
  33. package/dist/{codex-QGH2GRV6.js → codex-NYJWEPRQ.js} +4 -4
  34. package/dist/codex-NYJWEPRQ.js.map +1 -0
  35. package/dist/config-OTAVSMOD.js +75 -0
  36. package/dist/container-LUWGNBSS.js +1596 -0
  37. package/dist/context-OL4BVUV5.js +83 -0
  38. package/dist/{cursor-KQJTQ73D.js → cursor-3YHVD4NP.js} +4 -4
  39. package/dist/cursor-3YHVD4NP.js.map +1 -0
  40. package/dist/cursor-622RBRHH.js +97 -0
  41. package/dist/doctor-XSGQSD57.js +67 -0
  42. package/dist/doctor-service-TPOMFAIG.js +2 -0
  43. package/dist/goal-FMYYN2FR.js +138 -0
  44. package/dist/index.d.ts +16 -2
  45. package/dist/index.js +25 -16
  46. package/dist/index.js.map +1 -1
  47. package/dist/init-JU343RXK.js +165 -0
  48. package/dist/logs-PHPYWQ6I.js +207 -0
  49. package/dist/msg-FUWWLEKM.js +95 -0
  50. package/dist/opencode-FAMPSA6X.js +100 -0
  51. package/dist/opencode-FAMPSA6X.js.map +1 -0
  52. package/dist/opencode-WOR53TSC.js +98 -0
  53. package/dist/orchestrator-IYWBVA7J.js +5 -0
  54. package/dist/{orchestrator-KF4UY5GD.js.map → orchestrator-IYWBVA7J.js.map} +1 -1
  55. package/dist/orchestrator-QNAD7MFH.js +1433 -0
  56. package/dist/process-manager-HUVNAPQV.js +2 -0
  57. package/dist/registry-PQWRVNF2.js +2 -0
  58. package/dist/run-N72G5V2H.js +95 -0
  59. package/dist/shell-DVFHHYAZ.js +5 -0
  60. package/dist/{shell-UXJNTNBC.js → shell-NJNW3O6K.js} +6 -4
  61. package/dist/shell-NJNW3O6K.js.map +1 -0
  62. package/dist/shop-picker-2HY67UWP.js +79 -0
  63. package/dist/status-RZWN2C6C.js +56 -0
  64. package/dist/task-3O2OFSP6.js +221 -0
  65. package/dist/team-PFLP4PPL.js +97 -0
  66. package/dist/template-engine-5ZKVJMYA.js +3 -0
  67. package/dist/{template-engine-MFL5B677.js.map → template-engine-5ZKVJMYA.js.map} +1 -1
  68. package/dist/template-engine-AWIS56BL.js +3 -0
  69. package/dist/tui-LN5XHSQY.js +245 -0
  70. package/dist/update-YLP7FPNY.js +64 -0
  71. package/dist/update-check-4YKLGBFB.js +2 -0
  72. package/dist/workspace-manager-JM6U7JOH.js +215 -0
  73. package/package.json +2 -1
  74. package/readme.md +11 -4
  75. package/scripts/load-test.ts +478 -0
  76. package/scripts/postinstall.js +44 -2
  77. package/dist/App-NN7HR7UE.js +0 -20
  78. package/dist/agent-S4DKSX63.js +0 -9
  79. package/dist/agent-shop-D2RS4BZK.js +0 -2
  80. package/dist/chunk-3MQNQ7QW.js +0 -2
  81. package/dist/chunk-5AJ4LYO5.js +0 -8
  82. package/dist/chunk-6MJ7V6VY.js +0 -2
  83. package/dist/chunk-B4JQM4NU.js.map +0 -1
  84. package/dist/chunk-BGHCY7WY.js.map +0 -1
  85. package/dist/chunk-CDFA4IIQ.js +0 -2
  86. package/dist/chunk-CHRW4CLD.js +0 -2
  87. package/dist/chunk-HMMPM7MF.js +0 -3
  88. package/dist/chunk-HSBYJ5C5.js +0 -112
  89. package/dist/chunk-HXOMNULD.js +0 -2
  90. package/dist/chunk-IS3YBE2B.js +0 -3
  91. package/dist/chunk-KPCT44WU.js +0 -2
  92. package/dist/chunk-L26TK7Y5.js +0 -2
  93. package/dist/chunk-LXNRCJ22.js +0 -2
  94. package/dist/chunk-OQKREZUF.js +0 -11
  95. package/dist/chunk-PJ5DKXGR.js +0 -2
  96. package/dist/chunk-QFKVCNKL.js +0 -2
  97. package/dist/chunk-S3QYSBW4.js.map +0 -1
  98. package/dist/chunk-UMZEA3JT.js +0 -5
  99. package/dist/claude-GQZNDJ6L.js +0 -2
  100. package/dist/claude-INM52PTH.js.map +0 -1
  101. package/dist/clipboard-service-MYLSWM5E.js +0 -25
  102. package/dist/codex-QGH2GRV6.js.map +0 -1
  103. package/dist/codex-SJV7ZZBY.js +0 -2
  104. package/dist/config-CCSS2P7R.js +0 -2
  105. package/dist/container-NEKK5W2B.js +0 -6
  106. package/dist/context-GSMQHQES.js +0 -7
  107. package/dist/cursor-4JQOCP5X.js +0 -2
  108. package/dist/cursor-KQJTQ73D.js.map +0 -1
  109. package/dist/doctor-UAII4VWN.js +0 -2
  110. package/dist/doctor-service-PB7YBH3F.js +0 -2
  111. package/dist/goal-RFKFPR7M.js +0 -8
  112. package/dist/init-2D4RAN7B.js +0 -53
  113. package/dist/logs-UXFXVYCP.js +0 -12
  114. package/dist/msg-4SCLBO4K.js +0 -9
  115. package/dist/orchestrator-KF4UY5GD.js +0 -5
  116. package/dist/orchestrator-MFL3XK5L.js +0 -13
  117. package/dist/process-manager-33H27MQF.js +0 -2
  118. package/dist/registry-BO2PPRNG.js +0 -2
  119. package/dist/run-HSHRELOP.js +0 -3
  120. package/dist/shell-F42UUF3U.js +0 -2
  121. package/dist/shell-UXJNTNBC.js.map +0 -1
  122. package/dist/shop-picker-LE3SKFOX.js +0 -5
  123. package/dist/status-DLBNWSWM.js +0 -2
  124. package/dist/task-AP2TIOOF.js +0 -20
  125. package/dist/team-MSIBKOQC.js +0 -4
  126. package/dist/template-engine-MFL5B677.js +0 -3
  127. package/dist/template-engine-ONIDVD4F.js +0 -2
  128. package/dist/tui-PIQT4ZZ2.js +0 -2
  129. package/dist/update-PC2ENCKU.js +0 -2
  130. package/dist/update-check-HGMBDYHL.js +0 -2
  131. package/dist/workspace-manager-DYN3XJ7X.js +0 -3
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Load test script for AgentsOrchestryCLI.
4
+ *
5
+ * Simulates 10+ hours of continuous orchestrator operation with shell-adapter tasks.
6
+ * Monitors heap, directory sizes, file counts, and event listener counts every 5 minutes.
7
+ * Logs all metrics to a CSV file for post-analysis.
8
+ *
9
+ * Usage:
10
+ * npm run load-test # 10-hour run
11
+ * npm run load-test -- --duration 600 # 10-minute smoke test
12
+ */
13
+
14
+ import { mkdtemp, mkdir, writeFile, readdir, stat, rm } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { execSync } from 'node:child_process';
18
+ import { createWriteStream, WriteStream } from 'node:fs';
19
+
20
+ // ── Configuration ────────────────────────────────────────────────────────────
21
+
22
+ /** ms between task creation batches */
23
+ const TASK_INTERVAL_MS = 45_000;
24
+ /** ms between metric snapshots */
25
+ const MONITOR_INTERVAL_MS = 5 * 60_000;
26
+ /** ms until burst phase */
27
+ const BURST_AFTER_MS = 60 * 60_000;
28
+ /** number of tasks in burst */
29
+ const BURST_TASK_COUNT = 50;
30
+ /** default run duration: 10 hours */
31
+ const DEFAULT_DURATION_S = 10 * 3600;
32
+
33
+ // ── Types ─────────────────────────────────────────────────────────────────────
34
+
35
+ interface MetricsSnapshot {
36
+ ts: string;
37
+ elapsed_min: number;
38
+ heap_mb: number;
39
+ rss_mb: number;
40
+ external_mb: number;
41
+ orchestry_kb: number;
42
+ runs_count: number;
43
+ state_json_kb: number;
44
+ largest_jsonl_kb: number;
45
+ event_listeners: number;
46
+ tasks_total: number;
47
+ }
48
+
49
+ // ── Helpers ───────────────────────────────────────────────────────────────────
50
+
51
+ function sleep(ms: number): Promise<void> {
52
+ return new Promise((resolve) => setTimeout(resolve, ms));
53
+ }
54
+
55
+ function round1(n: number): number {
56
+ return Math.round(n * 10) / 10;
57
+ }
58
+
59
+ async function dirSizeKb(dir: string): Promise<number> {
60
+ try {
61
+ const out = execSync(`du -sk "${dir}"`, { stdio: 'pipe' }).toString();
62
+ return parseInt(out.split('\t')[0] ?? '0', 10);
63
+ } catch {
64
+ return 0;
65
+ }
66
+ }
67
+
68
+ async function countJsonlFiles(dir: string): Promise<number> {
69
+ try {
70
+ const files = await readdir(dir);
71
+ return files.filter((f) => f.endsWith('.jsonl')).length;
72
+ } catch {
73
+ return 0;
74
+ }
75
+ }
76
+
77
+ async function fileSizeKb(path: string): Promise<number> {
78
+ try {
79
+ const s = await stat(path);
80
+ return round1(s.size / 1024);
81
+ } catch {
82
+ return 0;
83
+ }
84
+ }
85
+
86
+ async function largestJsonlKb(dir: string): Promise<number> {
87
+ try {
88
+ const files = await readdir(dir);
89
+ const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
90
+ let max = 0;
91
+ await Promise.all(
92
+ jsonlFiles.map(async (f) => {
93
+ const kb = await fileSizeKb(join(dir, f));
94
+ if (kb > max) max = kb;
95
+ }),
96
+ );
97
+ return max;
98
+ } catch {
99
+ return 0;
100
+ }
101
+ }
102
+
103
+ function csvRow(snap: MetricsSnapshot): string {
104
+ return [
105
+ snap.ts,
106
+ snap.elapsed_min,
107
+ snap.heap_mb,
108
+ snap.rss_mb,
109
+ snap.external_mb,
110
+ snap.orchestry_kb,
111
+ snap.runs_count,
112
+ snap.state_json_kb,
113
+ snap.largest_jsonl_kb,
114
+ snap.event_listeners,
115
+ snap.tasks_total,
116
+ ].join(',') + '\n';
117
+ }
118
+
119
+ const CSV_HEADER =
120
+ 'ts,elapsed_min,heap_mb,rss_mb,external_mb,orchestry_kb,runs_count,state_json_kb,largest_jsonl_kb,event_listeners,tasks_total\n';
121
+
122
+ // ── Setup ─────────────────────────────────────────────────────────────────────
123
+
124
+ async function initOrchestry(projectRoot: string): Promise<void> {
125
+ const orchestryDir = join(projectRoot, '.orchestry');
126
+
127
+ await Promise.all([
128
+ mkdir(join(orchestryDir, 'tasks'), { recursive: true }),
129
+ mkdir(join(orchestryDir, 'agents'), { recursive: true }),
130
+ mkdir(join(orchestryDir, 'runs'), { recursive: true }),
131
+ mkdir(join(orchestryDir, 'goals'), { recursive: true }),
132
+ mkdir(join(orchestryDir, 'templates'), { recursive: true }),
133
+ mkdir(join(orchestryDir, 'logs'), { recursive: true }),
134
+ ]);
135
+
136
+ // Config — 3 concurrent agents, 10s poll
137
+ const config = {
138
+ project: { name: 'load-test' },
139
+ defaults: {
140
+ agent: {
141
+ adapter: 'shell',
142
+ approval_policy: 'auto',
143
+ max_turns: 5,
144
+ timeout_ms: 120_000,
145
+ stall_timeout_ms: 60_000,
146
+ workspace_mode: 'shared',
147
+ },
148
+ task: {
149
+ max_attempts: 3,
150
+ priority: 3,
151
+ },
152
+ },
153
+ scheduling: {
154
+ poll_interval_ms: 10_000,
155
+ max_concurrent_agents: 3,
156
+ retry_base_delay_ms: 5_000,
157
+ retry_max_delay_ms: 30_000,
158
+ },
159
+ };
160
+
161
+ const { dump } = await import('js-yaml');
162
+ await writeFile(join(orchestryDir, 'config.yml'), dump(config, { lineWidth: -1 }));
163
+
164
+ // Minimal prompt template
165
+ await writeFile(
166
+ join(orchestryDir, 'templates', 'default.md'),
167
+ '# Task: {{ task.title }}\n\n{{ task.description }}\n',
168
+ );
169
+ }
170
+
171
+ // ── Main ─────────────────────────────────────────────────────────────────────
172
+
173
+ async function main(): Promise<void> {
174
+ // Parse args
175
+ const args = process.argv.slice(2);
176
+ const dIdx = args.indexOf('--duration');
177
+ const durationSec = dIdx >= 0 ? parseInt(args[dIdx + 1] ?? String(DEFAULT_DURATION_S), 10) : DEFAULT_DURATION_S;
178
+ const DURATION_MS = durationSec * 1000;
179
+
180
+ console.log('='.repeat(65));
181
+ console.log('AgentsOrchestryCLI — Load Test');
182
+ console.log(`Duration: ${round1(durationSec / 3600)}h (${durationSec}s)`);
183
+ console.log(`Task interval: ${TASK_INTERVAL_MS / 1000}s`);
184
+ console.log(`Monitor interval: ${MONITOR_INTERVAL_MS / 60000}min`);
185
+ console.log(`Burst: ${BURST_TASK_COUNT} tasks after ${BURST_AFTER_MS / 3600000}h`);
186
+ console.log('='.repeat(65));
187
+
188
+ // ── 1. Create temp project ─────────────────────────────────────────────────
189
+ const projectRoot = await mkdtemp(join(tmpdir(), 'orch-load-'));
190
+ console.log(`\n[SETUP] Project root: ${projectRoot}`);
191
+ await initOrchestry(projectRoot);
192
+
193
+ // ── 2. Build container ─────────────────────────────────────────────────────
194
+ const { buildFullContainer } = await import('../src/container.js');
195
+ const context = {
196
+ projectRoot,
197
+ json: false,
198
+ quiet: true,
199
+ noColor: true,
200
+ ascii: true,
201
+ };
202
+ const c = await buildFullContainer(context);
203
+ const orchestryDir = join(projectRoot, '.orchestry');
204
+ console.log('[SETUP] Container built.');
205
+
206
+ // ── 3. Create agents ───────────────────────────────────────────────────────
207
+ const [agentSuccess, agentFail, agentLong] = await Promise.all([
208
+ c.agentService.create({
209
+ name: 'Worker Success',
210
+ adapter: 'shell',
211
+ command: 'sleep 5 && echo "task completed successfully"',
212
+ approval_policy: 'auto',
213
+ max_turns: 5,
214
+ timeout_ms: 30_000,
215
+ stall_timeout_ms: 20_000,
216
+ workspace_mode: 'shared',
217
+ }),
218
+ c.agentService.create({
219
+ name: 'Worker Fail',
220
+ adapter: 'shell',
221
+ command: 'sleep 2 && exit 1',
222
+ approval_policy: 'auto',
223
+ max_turns: 5,
224
+ timeout_ms: 30_000,
225
+ stall_timeout_ms: 20_000,
226
+ workspace_mode: 'shared',
227
+ }),
228
+ c.agentService.create({
229
+ name: 'Worker Long',
230
+ adapter: 'shell',
231
+ command: 'sleep 30 && echo "long task completed"',
232
+ approval_policy: 'auto',
233
+ max_turns: 5,
234
+ timeout_ms: 90_000,
235
+ stall_timeout_ms: 60_000,
236
+ workspace_mode: 'shared',
237
+ }),
238
+ ]);
239
+ console.log(`[SETUP] Agents: ${agentSuccess.id}, ${agentFail.id}, ${agentLong.id}`);
240
+
241
+ // ── 4. CSV output ──────────────────────────────────────────────────────────
242
+ const csvPath = join(process.cwd(), `load-test-metrics-${Date.now()}.csv`);
243
+ const csvFile: WriteStream = createWriteStream(csvPath);
244
+ csvFile.write(CSV_HEADER);
245
+ console.log(`[SETUP] Metrics CSV: ${csvPath}`);
246
+
247
+ // ── 5. Start orchestrator ──────────────────────────────────────────────────
248
+ await c.orchestrator.startWatch();
249
+ console.log('[SETUP] Orchestrator watch started.\n');
250
+
251
+ // ── State ──────────────────────────────────────────────────────────────────
252
+ let taskCounter = 0;
253
+ let burstFired = false;
254
+ let isShuttingDown = false;
255
+ const metricsHistory: MetricsSnapshot[] = [];
256
+ const startTime = Date.now();
257
+ const agents = [agentSuccess.id, agentFail.id, agentLong.id] as const;
258
+
259
+ // ── Collect metrics ────────────────────────────────────────────────────────
260
+ async function collectMetrics(elapsedMin: number): Promise<MetricsSnapshot> {
261
+ const mem = process.memoryUsage();
262
+ const runsDir = join(orchestryDir, 'runs');
263
+
264
+ const [orchestryKb, runsCount, stateKb, largestKb] = await Promise.all([
265
+ dirSizeKb(orchestryDir),
266
+ countJsonlFiles(runsDir),
267
+ fileSizeKb(join(orchestryDir, 'state.json')),
268
+ largestJsonlKb(runsDir),
269
+ ]);
270
+
271
+ // Sum known event type listener counts
272
+ let eventListeners = 0;
273
+ const eb = c.eventBus as unknown as { listenerCount?: (ev: string) => number };
274
+ if (typeof eb.listenerCount === 'function') {
275
+ const knownEvents = [
276
+ 'task:created',
277
+ 'task:status_changed',
278
+ 'agent:completed',
279
+ 'agent:error',
280
+ 'orchestrator:error',
281
+ 'orchestrator:shutdown',
282
+ ];
283
+ for (const ev of knownEvents) {
284
+ eventListeners += eb.listenerCount(ev);
285
+ }
286
+ }
287
+
288
+ return {
289
+ ts: new Date().toISOString(),
290
+ elapsed_min: elapsedMin,
291
+ heap_mb: round1(mem.heapUsed / 1024 / 1024),
292
+ rss_mb: round1(mem.rss / 1024 / 1024),
293
+ external_mb: round1(mem.external / 1024 / 1024),
294
+ orchestry_kb: orchestryKb,
295
+ runs_count: runsCount,
296
+ state_json_kb: stateKb,
297
+ largest_jsonl_kb: largestKb,
298
+ event_listeners: eventListeners,
299
+ tasks_total: taskCounter,
300
+ };
301
+ }
302
+
303
+ function printMetrics(snap: MetricsSnapshot): void {
304
+ console.log(`\n[METRICS T+${snap.elapsed_min}min @ ${snap.ts}]`);
305
+ console.log(` Heap: ${snap.heap_mb} MB`);
306
+ console.log(` RSS: ${snap.rss_mb} MB`);
307
+ console.log(` .orchestry/: ${snap.orchestry_kb} KB`);
308
+ console.log(` runs/ files: ${snap.runs_count}`);
309
+ console.log(` state.json: ${snap.state_json_kb} KB`);
310
+ console.log(` Largest JSONL: ${snap.largest_jsonl_kb} KB`);
311
+ console.log(` Event listeners: ${snap.event_listeners}`);
312
+ console.log(` Tasks created: ${snap.tasks_total}`);
313
+
314
+ if (snap.heap_mb > 300) {
315
+ console.warn(` [ALERT] ⚠ Heap ${snap.heap_mb}MB exceeds 300MB threshold!`);
316
+ }
317
+ if (snap.state_json_kb > 1024) {
318
+ console.warn(` [ALERT] ⚠ state.json ${snap.state_json_kb}KB exceeds 1MB threshold!`);
319
+ }
320
+ }
321
+
322
+ // ── Create task batch ──────────────────────────────────────────────────────
323
+ async function createTasks(count: number): Promise<void> {
324
+ const creations = Array.from({ length: count }, async (_, i) => {
325
+ const idx = (taskCounter + i) % 3;
326
+ const types = ['success', 'fail', 'long'] as const;
327
+ const type = types[idx]!;
328
+ const assignee = agents[idx]!;
329
+
330
+ try {
331
+ await c.taskService.create({
332
+ title: `Load test #${taskCounter + i + 1} (${type})`,
333
+ description: `Automated load test task, type: ${type}, seq: ${taskCounter + i + 1}`,
334
+ priority: 3,
335
+ assignee,
336
+ labels: [type],
337
+ max_attempts: 3,
338
+ });
339
+ } catch (err) {
340
+ console.error(`[WARN] Failed to create task: ${err}`);
341
+ }
342
+ });
343
+
344
+ await Promise.all(creations);
345
+ taskCounter += count;
346
+ const elapsed = Math.round((Date.now() - startTime) / 60000);
347
+ console.log(`[T+${elapsed}min] Created ${count} tasks (total: ${taskCounter})`);
348
+ }
349
+
350
+ // ── Graceful shutdown ──────────────────────────────────────────────────────
351
+ async function shutdown(): Promise<void> {
352
+ if (isShuttingDown) return;
353
+ isShuttingDown = true;
354
+ console.log('\n[SHUTDOWN] Stopping orchestrator...');
355
+
356
+ try {
357
+ await c.orchestrator.stop();
358
+ console.log('[SHUTDOWN] Orchestrator stopped.');
359
+ } catch (err) {
360
+ console.error(`[SHUTDOWN] Stop error: ${err}`);
361
+ }
362
+
363
+ // Final metrics
364
+ const elapsed_min = Math.round((Date.now() - startTime) / 60000);
365
+ const finalSnap = await collectMetrics(elapsed_min);
366
+ metricsHistory.push(finalSnap);
367
+ printMetrics(finalSnap);
368
+ csvFile.write(csvRow(finalSnap));
369
+ csvFile.end();
370
+
371
+ // ── Final report ─────────────────────────────────────────────────────────
372
+ console.log('\n' + '='.repeat(65));
373
+ console.log('FINAL REPORT');
374
+ console.log('='.repeat(65));
375
+ console.log(`Total run time: ${elapsed_min} min (${round1(elapsed_min / 60)}h)`);
376
+ console.log(`Tasks created: ${taskCounter}`);
377
+ console.log(`Final heap: ${finalSnap.heap_mb} MB`);
378
+ console.log(`Final RSS: ${finalSnap.rss_mb} MB`);
379
+ console.log(`Runs dir files: ${finalSnap.runs_count}`);
380
+ console.log(`state.json: ${finalSnap.state_json_kb} KB`);
381
+ console.log(`Metrics CSV: ${csvPath}`);
382
+
383
+ if (metricsHistory.length > 0) {
384
+ const peakHeap = Math.max(...metricsHistory.map((s) => s.heap_mb));
385
+ const peakRss = Math.max(...metricsHistory.map((s) => s.rss_mb));
386
+ console.log(`\nPeak heap: ${peakHeap} MB`);
387
+ console.log(`Peak RSS: ${peakRss} MB`);
388
+
389
+ // SLO checks
390
+ const slos: Array<{ name: string; pass: boolean }> = [
391
+ { name: `Heap < 300MB (peak: ${peakHeap}MB)`, pass: peakHeap < 300 },
392
+ {
393
+ name: `state.json < 1MB after 500 tasks (${finalSnap.state_json_kb}KB, ${taskCounter} tasks)`,
394
+ pass: taskCounter < 500 || finalSnap.state_json_kb < 1024,
395
+ },
396
+ ];
397
+
398
+ console.log('\nSLO Results:');
399
+ let allPass = true;
400
+ for (const slo of slos) {
401
+ const icon = slo.pass ? '✓' : '✗';
402
+ console.log(` ${icon} ${slo.name}`);
403
+ if (!slo.pass) allPass = false;
404
+ }
405
+ console.log('\n' + (allPass ? 'All SLOs PASS' : 'Some SLOs FAIL'));
406
+ }
407
+
408
+ // Cleanup temp dir
409
+ try {
410
+ await rm(projectRoot, { recursive: true, force: true });
411
+ console.log(`\n[CLEANUP] Removed ${projectRoot}`);
412
+ } catch {
413
+ console.warn(`[CLEANUP] Could not remove ${projectRoot}`);
414
+ }
415
+
416
+ process.exit(0);
417
+ }
418
+
419
+ process.once('SIGINT', () => { void shutdown(); });
420
+ process.once('SIGTERM', () => { void shutdown(); });
421
+
422
+ // ── Main loop ──────────────────────────────────────────────────────────────
423
+ let lastTaskTime = Date.now();
424
+ let lastMonitorTime = Date.now();
425
+
426
+ // Initial batch
427
+ await createTasks(3);
428
+
429
+ while (!isShuttingDown && Date.now() - startTime < DURATION_MS) {
430
+ const now = Date.now();
431
+ const elapsed = now - startTime;
432
+
433
+ // Create tasks on interval
434
+ if (now - lastTaskTime >= TASK_INTERVAL_MS) {
435
+ const count = 2 + (Math.random() < 0.33 ? 1 : 0); // 2 or 3
436
+ await createTasks(count);
437
+ lastTaskTime = now;
438
+ }
439
+
440
+ // Burst at 1 hour
441
+ if (!burstFired && elapsed >= BURST_AFTER_MS) {
442
+ burstFired = true;
443
+ console.log(`\n[BURST] T+1h reached — creating ${BURST_TASK_COUNT} tasks in one batch...`);
444
+ await createTasks(BURST_TASK_COUNT);
445
+ }
446
+
447
+ // Monitor on interval
448
+ if (now - lastMonitorTime >= MONITOR_INTERVAL_MS) {
449
+ const elapsedMin = Math.round(elapsed / 60000);
450
+ const snap = await collectMetrics(elapsedMin);
451
+ metricsHistory.push(snap);
452
+ printMetrics(snap);
453
+ csvFile.write(csvRow(snap));
454
+ lastMonitorTime = now;
455
+ }
456
+
457
+ // Lightweight idle sleep (5s checks)
458
+ await sleep(5_000);
459
+ }
460
+
461
+ if (!isShuttingDown) {
462
+ await shutdown();
463
+ }
464
+ }
465
+
466
+ // AbortError is expected when the orchestrator kills running child processes on shutdown.
467
+ // Without this handler Node.js would crash with an unhandled 'error' event on ChildProcess.
468
+ process.on('uncaughtException', (err: Error) => {
469
+ const code = (err as NodeJS.ErrnoException).code;
470
+ if (code === 'ABORT_ERR' || err.name === 'AbortError') return;
471
+ console.error('[UNCAUGHT ERROR]', err);
472
+ process.exit(1);
473
+ });
474
+
475
+ main().catch((err: unknown) => {
476
+ console.error('[FATAL]', err);
477
+ process.exit(1);
478
+ });
@@ -1,11 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Post-install banner shown once after `npm install -g @oxgeneral/orch`.
4
+ * Post-install: patch Ink caches + show banner.
5
5
  * Pure Node.js, no dependencies.
6
6
  */
7
7
 
8
- // Skip in CI or non-interactive environments
8
+ // Patch Ink's unbounded caches before anything else
9
+ try {
10
+ const { readFileSync, writeFileSync, existsSync } = require('node:fs');
11
+ const { join } = require('node:path');
12
+ const inkBuild = join(__dirname, '..', 'node_modules', 'ink', 'build');
13
+ const MAX = 2000;
14
+
15
+ const wrapPath = join(inkBuild, 'wrap-text.js');
16
+ if (existsSync(wrapPath)) {
17
+ let s = readFileSync(wrapPath, 'utf8');
18
+ if (!s.includes('_lruKeys')) {
19
+ s = s.replace('const cache = {};', `const cache = {};\nconst _lruKeys = [];\nconst _MAX = ${MAX};`);
20
+ s = s.replace('cache[cacheKey] = wrappedText;', `cache[cacheKey] = wrappedText;\n _lruKeys.push(cacheKey);\n if (_lruKeys.length > _MAX) { delete cache[_lruKeys.shift()]; }`);
21
+ writeFileSync(wrapPath, s);
22
+ }
23
+ }
24
+
25
+ const measurePath = join(inkBuild, 'measure-text.js');
26
+ if (existsSync(measurePath)) {
27
+ let s = readFileSync(measurePath, 'utf8');
28
+ if (!s.includes('_MAX_MT')) {
29
+ s = s.replace('const cache = new Map();', `const cache = new Map();\nconst _MAX_MT = ${MAX};`);
30
+ s = s.replace('cache.set(text, dimensions);', `cache.set(text, dimensions);\n if (cache.size > _MAX_MT) { const first = cache.keys().next().value; cache.delete(first); }`);
31
+ writeFileSync(measurePath, s);
32
+ }
33
+ }
34
+ // Patch output.js: add LRU eviction to OutputCaches maps
35
+ const outputPath = join(inkBuild, 'output.js');
36
+ if (existsSync(outputPath)) {
37
+ let s = readFileSync(outputPath, 'utf8');
38
+ if (!s.includes('_OC_MAX')) {
39
+ // Add LRU bound to all three Maps in OutputCaches
40
+ const lru = (prop) => `\n if (this.${prop}.size > _OC_MAX) { const k = this.${prop}.keys().next().value; this.${prop}.delete(k); }`;
41
+ s = s.replace('class OutputCaches {', `const _OC_MAX = ${MAX};\nclass OutputCaches {`);
42
+ s = s.replace('this.styledChars.set(line, cached);', `this.styledChars.set(line, cached);${lru('styledChars')}`);
43
+ s = s.replace('this.widths.set(text, cached);', `this.widths.set(text, cached);${lru('widths')}`);
44
+ s = s.replace('this.blockWidths.set(text, cached);', `this.blockWidths.set(text, cached);${lru('blockWidths')}`);
45
+ writeFileSync(outputPath, s);
46
+ }
47
+ }
48
+ } catch { /* non-fatal: caches will just be unbounded */ }
49
+
50
+ // Skip banner in CI or non-interactive environments
9
51
  if (process.env.CI || !process.stderr.isTTY) process.exit(0);
10
52
 
11
53
  // Color values synced with src/cli/output.ts colors map