@pushpalsdev/cli 1.0.34 → 1.0.36

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.
@@ -17,6 +17,258 @@ import {
17
17
  import { basename, delimiter, dirname, extname, join as join2, resolve as resolve4, win32 as pathWin32 } from "path";
18
18
  import { createInterface } from "readline";
19
19
 
20
+ // ../shared/src/localbuddy_runtime.ts
21
+ var TRUTHY = new Set(["1", "true", "yes", "on"]);
22
+ var FALSY = new Set(["0", "false", "no", "off"]);
23
+
24
+ // ../../scripts/start_runtime_services.ts
25
+ var DEFAULT_SERVICE_MANAGER_POLL_MS = 1000;
26
+ var DEFAULT_SERVICE_MANAGER_MAX_RESTART_ATTEMPTS = 4;
27
+ var DEFAULT_SERVICE_MANAGER_STABLE_WINDOW_MS = 60000;
28
+ var DEFAULT_SERVICE_MANAGER_BASE_BACKOFF_MS = 2000;
29
+ var DEFAULT_SERVICE_MANAGER_MAX_BACKOFF_MS = 30000;
30
+ function formatEmbeddedRuntimeHealthLines(health) {
31
+ if (!health)
32
+ return [];
33
+ const lines = [`[pushpals] embeddedRuntime=${health.state} detail=${health.detail}`];
34
+ if (health.action) {
35
+ lines.push(`[pushpals] embeddedRuntimeAction=${health.action}`);
36
+ }
37
+ return lines;
38
+ }
39
+ function computeServiceRestartBackoffMs(attempt) {
40
+ const boundedAttempt = Math.max(1, Math.floor(attempt));
41
+ const exponential = DEFAULT_SERVICE_MANAGER_BASE_BACKOFF_MS * Math.pow(2, boundedAttempt - 1);
42
+ return Math.max(DEFAULT_SERVICE_MANAGER_BASE_BACKOFF_MS, Math.min(DEFAULT_SERVICE_MANAGER_MAX_BACKOFF_MS, Math.floor(exponential)));
43
+ }
44
+ function shouldRestartService(attempts, maxAttempts = DEFAULT_SERVICE_MANAGER_MAX_RESTART_ATTEMPTS) {
45
+ const normalizedAttempts = Math.max(0, Math.floor(attempts));
46
+ const normalizedMax = Math.max(1, Math.floor(maxAttempts));
47
+ return normalizedAttempts < normalizedMax;
48
+ }
49
+ function pipeProcessStreamToLines(stream, onLine) {
50
+ if (!stream || typeof stream === "number" || typeof stream.getReader !== "function")
51
+ return;
52
+ const reader = stream.getReader();
53
+ const decoder = new TextDecoder;
54
+ let pending = "";
55
+ (async () => {
56
+ try {
57
+ while (true) {
58
+ const { done, value } = await reader.read();
59
+ if (done)
60
+ break;
61
+ pending += decoder.decode(value, { stream: true });
62
+ const lines = pending.split(/\r?\n/);
63
+ pending = lines.pop() ?? "";
64
+ for (const line of lines) {
65
+ const trimmed = line.trimEnd();
66
+ if (!trimmed)
67
+ continue;
68
+ onLine?.(trimmed);
69
+ }
70
+ }
71
+ const rest = decoder.decode();
72
+ if (rest)
73
+ pending += rest;
74
+ const tail = pending.trimEnd();
75
+ if (tail)
76
+ onLine?.(tail);
77
+ } catch {} finally {
78
+ reader.releaseLock();
79
+ }
80
+ })();
81
+ }
82
+ function spawnManagedService(spec) {
83
+ const env = { ...spec.env ?? {} };
84
+ const proc = Bun.spawn(spec.command, {
85
+ cwd: spec.cwd,
86
+ env,
87
+ stdout: "pipe",
88
+ stderr: "pipe"
89
+ });
90
+ pipeProcessStreamToLines(proc.stdout, spec.onStdoutLine);
91
+ pipeProcessStreamToLines(proc.stderr, spec.onStderrLine);
92
+ const service = {
93
+ name: spec.name,
94
+ proc,
95
+ command: [...spec.command],
96
+ cwd: spec.cwd,
97
+ env,
98
+ exited: false,
99
+ exitCode: null,
100
+ launchedAtMs: Date.now(),
101
+ logPath: spec.logPath
102
+ };
103
+ proc.exited.then((code) => {
104
+ service.exited = true;
105
+ service.exitCode = code;
106
+ });
107
+ return service;
108
+ }
109
+
110
+ class ServiceManager {
111
+ services = new Map;
112
+ launchSpecs = new Map;
113
+ stateByService = new Map;
114
+ degradedServiceReasons = new Map;
115
+ pollMs;
116
+ maxRestartAttempts;
117
+ stableWindowMs;
118
+ computeRestartBackoffMs;
119
+ degradedAction;
120
+ spawnService;
121
+ onHealthChange;
122
+ onServiceDegraded;
123
+ onEvent;
124
+ timer;
125
+ stopped = false;
126
+ constructor(options = {}) {
127
+ this.pollMs = Math.max(50, Math.floor(options.pollMs ?? DEFAULT_SERVICE_MANAGER_POLL_MS));
128
+ this.maxRestartAttempts = Math.max(1, Math.floor(options.maxRestartAttempts ?? DEFAULT_SERVICE_MANAGER_MAX_RESTART_ATTEMPTS));
129
+ this.stableWindowMs = Math.max(1000, Math.floor(options.stableWindowMs ?? DEFAULT_SERVICE_MANAGER_STABLE_WINDOW_MS));
130
+ this.computeRestartBackoffMs = options.computeRestartBackoffMs ?? computeServiceRestartBackoffMs;
131
+ this.degradedAction = options.degradedAction ?? "Inspect the affected service logs or restart the runtime after fixing the failure.";
132
+ this.spawnService = options.spawnService ?? spawnManagedService;
133
+ this.onHealthChange = options.onHealthChange;
134
+ this.onServiceDegraded = options.onServiceDegraded;
135
+ this.onEvent = options.onEvent;
136
+ this.timer = setInterval(() => this.tick(), this.pollMs);
137
+ }
138
+ startService(spec) {
139
+ this.launchSpecs.set(spec.name, {
140
+ ...spec,
141
+ command: [...spec.command],
142
+ env: { ...spec.env ?? {} }
143
+ });
144
+ const service = this.spawnService(spec);
145
+ this.services.set(spec.name, service);
146
+ return service;
147
+ }
148
+ getServices() {
149
+ return Array.from(this.services.values());
150
+ }
151
+ getService(name) {
152
+ return this.services.get(name) ?? null;
153
+ }
154
+ getHealth() {
155
+ if (this.degradedServiceReasons.size === 0)
156
+ return null;
157
+ const detail = Array.from(this.degradedServiceReasons.entries()).map(([name, reason]) => `${name}: ${reason}`).join(" | ");
158
+ return {
159
+ state: "degraded",
160
+ detail,
161
+ action: this.degradedAction
162
+ };
163
+ }
164
+ stop() {
165
+ if (this.stopped)
166
+ return;
167
+ this.stopped = true;
168
+ clearInterval(this.timer);
169
+ for (const state of this.stateByService.values()) {
170
+ if (!state.pendingRestartTimer)
171
+ continue;
172
+ clearTimeout(state.pendingRestartTimer);
173
+ state.pendingRestartTimer = null;
174
+ }
175
+ for (const service of this.services.values()) {
176
+ try {
177
+ const pid = service.proc.pid;
178
+ if (process.platform === "win32" && typeof pid === "number" && pid > 0) {
179
+ Bun.spawnSync(["taskkill", "/PID", String(pid), "/T", "/F"], {
180
+ stdin: "ignore",
181
+ stdout: "ignore",
182
+ stderr: "ignore"
183
+ });
184
+ } else {
185
+ service.proc.kill();
186
+ }
187
+ } catch {}
188
+ }
189
+ }
190
+ ensureState(name) {
191
+ const existing = this.stateByService.get(name);
192
+ if (existing)
193
+ return existing;
194
+ const created = {
195
+ attempts: 0,
196
+ nextRestartAtMs: 0,
197
+ lastRestartReason: "",
198
+ pendingRestartTimer: null
199
+ };
200
+ this.stateByService.set(name, created);
201
+ return created;
202
+ }
203
+ emitHealthChange() {
204
+ this.onHealthChange?.(this.getHealth());
205
+ }
206
+ emitEvent(level, line) {
207
+ this.onEvent?.(level, line);
208
+ }
209
+ tick() {
210
+ if (this.stopped)
211
+ return;
212
+ const now = Date.now();
213
+ for (const [name, service] of this.services.entries()) {
214
+ const launchSpec = this.launchSpecs.get(name);
215
+ if (!launchSpec)
216
+ continue;
217
+ const state = this.ensureState(name);
218
+ if (!service.exited) {
219
+ if (state.attempts > 0 && now - service.launchedAtMs >= this.stableWindowMs) {
220
+ state.attempts = 0;
221
+ state.nextRestartAtMs = 0;
222
+ state.lastRestartReason = "";
223
+ }
224
+ continue;
225
+ }
226
+ if (state.pendingRestartTimer)
227
+ continue;
228
+ if (state.nextRestartAtMs > now)
229
+ continue;
230
+ const reason = `exit code ${service.exitCode ?? "unknown"}`;
231
+ if (!shouldRestartService(state.attempts, this.maxRestartAttempts)) {
232
+ this.emitEvent("error", `Managed ${name} exited (${reason}) and reached restart limit (${state.attempts}/${this.maxRestartAttempts}).`);
233
+ this.launchSpecs.delete(name);
234
+ if (!this.degradedServiceReasons.has(name)) {
235
+ const degradationReason = `reached restart limit after ${reason} (${state.attempts}/${this.maxRestartAttempts})`;
236
+ this.degradedServiceReasons.set(name, degradationReason);
237
+ const health = this.getHealth();
238
+ if (health) {
239
+ this.onHealthChange?.(health);
240
+ this.onServiceDegraded?.(name, degradationReason, health);
241
+ }
242
+ }
243
+ continue;
244
+ }
245
+ const nextAttempt = state.attempts + 1;
246
+ state.lastRestartReason = reason;
247
+ const backoffMs = Math.max(1, Math.floor(this.computeRestartBackoffMs(nextAttempt)));
248
+ this.emitEvent("warn", `Managed ${name} exited (${reason}); restarting attempt ${nextAttempt}/${this.maxRestartAttempts} in ${backoffMs}ms.`);
249
+ state.nextRestartAtMs = now + backoffMs;
250
+ state.pendingRestartTimer = setTimeout(() => {
251
+ state.pendingRestartTimer = null;
252
+ state.nextRestartAtMs = 0;
253
+ if (this.stopped)
254
+ return;
255
+ const current = this.services.get(name);
256
+ if (!current || !current.exited)
257
+ return;
258
+ const spec = this.launchSpecs.get(name);
259
+ if (!spec)
260
+ return;
261
+ if (!shouldRestartService(state.attempts, this.maxRestartAttempts))
262
+ return;
263
+ state.attempts += 1;
264
+ const restarted = this.spawnService(spec);
265
+ this.services.set(name, restarted);
266
+ this.emitEvent("log", `Restarted managed ${name}.`);
267
+ }, backoffMs);
268
+ }
269
+ }
270
+ }
271
+
20
272
  // ../shared/src/client_preflight.ts
21
273
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
22
274
  import { relative, resolve as resolve2 } from "path";
@@ -101,8 +353,8 @@ function normalizeLoopbackHttpUrl(value, fallbackPort) {
101
353
  // ../shared/src/config.ts
102
354
  var PROJECT_ROOT = resolve(import.meta.dir, "..", "..", "..");
103
355
  var DEFAULT_CONFIG_DIR = "configs";
104
- var TRUTHY = new Set(["1", "true", "yes", "on"]);
105
- var FALSY = new Set(["0", "false", "no", "off"]);
356
+ var TRUTHY2 = new Set(["1", "true", "yes", "on"]);
357
+ var FALSY2 = new Set(["0", "false", "no", "off"]);
106
358
  var DEFAULT_WORKERPALS_QUALITY_CRITIC_MIN_SCORE = 8;
107
359
  var DEFAULT_WORKERPALS_QUALITY_MAX_AUTO_REVISIONS = 1;
108
360
  var DEFAULT_WORKERPALS_FILE_MODIFYING_JOBS = ["task.execute"];
@@ -133,9 +385,9 @@ function parseBoolEnv(name) {
133
385
  const raw = (process.env[name] ?? "").trim().toLowerCase();
134
386
  if (!raw)
135
387
  return null;
136
- if (TRUTHY.has(raw))
388
+ if (TRUTHY2.has(raw))
137
389
  return true;
138
- if (FALSY.has(raw))
390
+ if (FALSY2.has(raw))
139
391
  return false;
140
392
  return null;
141
393
  }
@@ -192,9 +444,9 @@ function asBoolean(value, fallback) {
192
444
  return value;
193
445
  if (typeof value === "string") {
194
446
  const lowered = value.trim().toLowerCase();
195
- if (TRUTHY.has(lowered))
447
+ if (TRUTHY2.has(lowered))
196
448
  return true;
197
- if (FALSY.has(lowered))
449
+ if (FALSY2.has(lowered))
198
450
  return false;
199
451
  }
200
452
  return fallback;
@@ -1217,6 +1469,7 @@ var DEFAULT_RUNTIME_BOOT_TIMEOUT_MS = 90000;
1217
1469
  var DEFAULT_RUNTIME_BOOT_POLL_MS = 1000;
1218
1470
  var DEFAULT_SERVER_BOOT_TIMEOUT_MS = 20000;
1219
1471
  var DEFAULT_SERVICE_STABILITY_GRACE_MS = 4000;
1472
+ var EMBEDDED_SERVICE_RESTART_MAX_ATTEMPTS = 4;
1220
1473
  var WORKERPAL_STARTUP_READINESS_PROBE_MAX_MS = 15000;
1221
1474
  var EMBEDDED_RUNTIME_SAFETY_CAP_DISABLE_ENV = "PUSHPALS_DISABLE_EMBEDDED_SAFETY_CAPS";
1222
1475
  var EMBEDDED_RUNTIME_WINDOWS_SAFETY_CAPS = {
@@ -1292,6 +1545,9 @@ function formatWorkerExecutionReadinessLines(readiness) {
1292
1545
  }
1293
1546
  return lines;
1294
1547
  }
1548
+ function formatEmbeddedRuntimeHealthLines2(health) {
1549
+ return formatEmbeddedRuntimeHealthLines(health);
1550
+ }
1295
1551
  function summarizeWorkerStatusRows(workers) {
1296
1552
  const onlineWorkers = workers.filter((worker) => Boolean(worker?.isOnline) && String(worker?.status ?? "").trim().toLowerCase() !== "offline");
1297
1553
  const idleWorkers = onlineWorkers.filter((worker) => Number(worker?.activeJobCount ?? 0) <= 0);
@@ -2282,71 +2538,6 @@ async function ensureRuntimeBinaries(runtimeRoot, runtimeTag) {
2282
2538
  console.log("[pushpals] Embedded runtime binaries are ready.");
2283
2539
  return runtimeBinaries;
2284
2540
  }
2285
- function spawnRuntimeService(name, command, cwd, env, logPath, runtimeServicesLogPath) {
2286
- const header = `[pushpals] service=${name} command=${command.join(" ")} cwd=${cwd}`;
2287
- writeFileSync(logPath, `${header}
2288
- `, "utf8");
2289
- if (runtimeServicesLogPath) {
2290
- appendRuntimeServicesLogLine(runtimeServicesLogPath, header);
2291
- }
2292
- const proc = Bun.spawn(command, {
2293
- cwd,
2294
- env,
2295
- stdout: "pipe",
2296
- stderr: "pipe"
2297
- });
2298
- const pipeToLog = async (stream, channel) => {
2299
- if (!stream)
2300
- return;
2301
- const reader = stream.getReader();
2302
- const decoder = new TextDecoder;
2303
- let pending = "";
2304
- while (true) {
2305
- const { done, value } = await reader.read();
2306
- if (done)
2307
- break;
2308
- const chunk = decoder.decode(value, { stream: true });
2309
- if (!chunk)
2310
- continue;
2311
- pending += chunk;
2312
- const lines = pending.split(/\r?\n/);
2313
- pending = lines.pop() ?? "";
2314
- for (const line of lines) {
2315
- const serviceLine = `[${channel}] ${line}`;
2316
- appendFileSync(logPath, `${serviceLine}
2317
- `, "utf8");
2318
- if (runtimeServicesLogPath) {
2319
- appendRuntimeServicesLogLine(runtimeServicesLogPath, `[${name}] ${serviceLine}`);
2320
- }
2321
- }
2322
- }
2323
- const rest = decoder.decode();
2324
- if (rest)
2325
- pending += rest;
2326
- if (pending.trim().length > 0) {
2327
- const serviceLine = `[${channel}] ${pending.trimEnd()}`;
2328
- appendFileSync(logPath, `${serviceLine}
2329
- `, "utf8");
2330
- if (runtimeServicesLogPath) {
2331
- appendRuntimeServicesLogLine(runtimeServicesLogPath, `[${name}] ${serviceLine}`);
2332
- }
2333
- }
2334
- };
2335
- pipeToLog(proc.stdout, "stdout");
2336
- pipeToLog(proc.stderr, "stderr");
2337
- const service = {
2338
- name,
2339
- proc,
2340
- logPath,
2341
- exited: false,
2342
- exitCode: null
2343
- };
2344
- proc.exited.then((code) => {
2345
- service.exited = true;
2346
- service.exitCode = code;
2347
- });
2348
- return service;
2349
- }
2350
2541
  function buildServiceStopCommand(pid, platform = process.platform) {
2351
2542
  if (platform === "win32" && typeof pid === "number" && pid > 0) {
2352
2543
  return ["taskkill", "/PID", String(pid), "/T", "/F"];
@@ -2560,6 +2751,12 @@ function quoteWindowsCmdArg(value) {
2560
2751
  function isOptionalEmbeddedService(name) {
2561
2752
  return name === "source_control_manager";
2562
2753
  }
2754
+ function computeEmbeddedServiceRestartBackoffMs(attempt) {
2755
+ return computeServiceRestartBackoffMs(attempt);
2756
+ }
2757
+ function shouldRestartEmbeddedService(attempts, maxAttempts = EMBEDDED_SERVICE_RESTART_MAX_ATTEMPTS) {
2758
+ return shouldRestartService(attempts, maxAttempts);
2759
+ }
2563
2760
  async function canSpawnCommand(command, cwd, env) {
2564
2761
  try {
2565
2762
  const proc = Bun.spawn(command, {
@@ -3482,7 +3679,6 @@ async function autoStartRuntimeServices(opts) {
3482
3679
  if (resolvedGitBinary) {
3483
3680
  applyResolvedGitBinaryToRuntimeEnv(runtimeEnv, resolvedGitBinary);
3484
3681
  }
3485
- const services = [];
3486
3682
  const startupStartedAt = Date.now();
3487
3683
  const startupPhases = [];
3488
3684
  const recordStartupPhase = (name, startedAt, status) => {
@@ -3521,23 +3717,70 @@ async function autoStartRuntimeServices(opts) {
3521
3717
  console.log(`[pushpals] service log (localbuddy)=${serviceLogPaths.localbuddy}`);
3522
3718
  console.log(`[pushpals] service log (remotebuddy)=${serviceLogPaths.remotebuddy}`);
3523
3719
  console.log(`[pushpals] service log (source_control_manager)=${serviceLogPaths.source_control_manager}`);
3720
+ const serviceManager = new ServiceManager({
3721
+ degradedAction: "Inspect the embedded service log or restart pushpals after fixing the runtime failure.",
3722
+ onEvent: (level, line) => {
3723
+ const cliLine = `[pushpals] ${line.replace(/^Managed /, "Embedded ").replace(/^Restarted managed /, "Restarted embedded ")}`;
3724
+ if (level === "error") {
3725
+ console.error(cliLine);
3726
+ } else if (level === "warn") {
3727
+ console.warn(cliLine);
3728
+ } else {
3729
+ console.log(cliLine);
3730
+ }
3731
+ appendRuntimeServicesLogLine(runtimeServicesLogPath, cliLine);
3732
+ },
3733
+ onHealthChange: (health) => {
3734
+ for (const line of formatEmbeddedRuntimeHealthLines2(health)) {
3735
+ console.error(line);
3736
+ appendRuntimeServicesLogLine(runtimeServicesLogPath, line);
3737
+ }
3738
+ }
3739
+ });
3740
+ const launchService = (name, command) => {
3741
+ const logPath = serviceLogPaths[name];
3742
+ const header = `[pushpals] service=${name} command=${command.join(" ")} cwd=${opts.repoRoot}`;
3743
+ writeFileSync(logPath, `${header}
3744
+ `, "utf8");
3745
+ appendRuntimeServicesLogLine(runtimeServicesLogPath, header);
3746
+ return serviceManager.startService({
3747
+ name,
3748
+ color: "",
3749
+ command,
3750
+ cwd: opts.repoRoot,
3751
+ env: runtimeEnv,
3752
+ logPath,
3753
+ onStdoutLine: (line) => {
3754
+ const serviceLine = `[stdout] ${line}`;
3755
+ appendFileSync(logPath, `${serviceLine}
3756
+ `, "utf8");
3757
+ appendRuntimeServicesLogLine(runtimeServicesLogPath, `[${name}] ${serviceLine}`);
3758
+ },
3759
+ onStderrLine: (line) => {
3760
+ const serviceLine = `[stderr] ${line}`;
3761
+ appendFileSync(logPath, `${serviceLine}
3762
+ `, "utf8");
3763
+ appendRuntimeServicesLogLine(runtimeServicesLogPath, `[${name}] ${serviceLine}`);
3764
+ }
3765
+ });
3766
+ };
3524
3767
  const serverHealthy = await probeServer(opts.serverUrl);
3525
3768
  if (!serverHealthy) {
3526
3769
  const serverPhaseStartedAt = Date.now();
3527
3770
  console.log("[pushpals] Starting embedded server...");
3528
- const serverService = spawnRuntimeService("server", [runtimeBinaries.server], opts.repoRoot, runtimeEnv, serviceLogPaths.server, runtimeServicesLogPath);
3529
- services.push(serverService);
3530
- console.log(`[pushpals] server log: ${serverService.logPath}`);
3771
+ const serverService = launchService("server", [runtimeBinaries.server]);
3772
+ const serverLogPath = serverService.logPath ?? serviceLogPaths.server;
3773
+ console.log(`[pushpals] server log: ${serverLogPath}`);
3531
3774
  const serverDeadline = Date.now() + DEFAULT_SERVER_BOOT_TIMEOUT_MS;
3532
3775
  let serverIsReady = false;
3533
3776
  while (Date.now() < serverDeadline) {
3534
3777
  if (serverService.exited) {
3535
- const tail = readLogTail(serverService.logPath);
3778
+ const tail = readLogTail(serverLogPath);
3536
3779
  appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] embedded server exited during bootstrap (code=${serverService.exitCode ?? "unknown"}).`);
3537
3780
  recordStartupPhase("server", serverPhaseStartedAt, "exited");
3538
3781
  emitStartupTimingSummary("failed", "server exited during bootstrap");
3539
- stopRuntimeServices(services);
3540
- throw new Error(`Embedded server exited during bootstrap (code=${serverService.exitCode ?? "unknown"}). ` + `See ${serverService.logPath}${tail ? `
3782
+ stopRuntimeServices(serviceManager.getServices());
3783
+ throw new Error(`Embedded server exited during bootstrap (code=${serverService.exitCode ?? "unknown"}). ` + `See ${serverLogPath}${tail ? `
3541
3784
  --- server log tail ---
3542
3785
  ${tail}` : ""}`);
3543
3786
  }
@@ -3548,12 +3791,12 @@ ${tail}` : ""}`);
3548
3791
  await Bun.sleep(DEFAULT_RUNTIME_BOOT_POLL_MS);
3549
3792
  }
3550
3793
  if (!serverIsReady) {
3551
- const tail = readLogTail(serverService.logPath);
3794
+ const tail = readLogTail(serverLogPath);
3552
3795
  appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] embedded server did not become healthy within ${DEFAULT_SERVER_BOOT_TIMEOUT_MS}ms.`);
3553
3796
  recordStartupPhase("server", serverPhaseStartedAt, "timeout");
3554
3797
  emitStartupTimingSummary("failed", "server health timeout");
3555
- stopRuntimeServices(services);
3556
- throw new Error(`Embedded server did not become healthy within ${DEFAULT_SERVER_BOOT_TIMEOUT_MS}ms. ` + `See ${serverService.logPath}${tail ? `
3798
+ stopRuntimeServices(serviceManager.getServices());
3799
+ throw new Error(`Embedded server did not become healthy within ${DEFAULT_SERVER_BOOT_TIMEOUT_MS}ms. ` + `See ${serverLogPath}${tail ? `
3557
3800
  --- server log tail ---
3558
3801
  ${tail}` : ""}`);
3559
3802
  }
@@ -3567,9 +3810,8 @@ ${tail}` : ""}`);
3567
3810
  if (localBuddyEnabled) {
3568
3811
  const localBuddyPhaseStartedAt = Date.now();
3569
3812
  console.log("[pushpals] Starting embedded LocalBuddy...");
3570
- const localbuddyService = spawnRuntimeService("localbuddy", [runtimeBinaries.localbuddy], opts.repoRoot, runtimeEnv, serviceLogPaths.localbuddy, runtimeServicesLogPath);
3571
- services.push(localbuddyService);
3572
- console.log(`[pushpals] localbuddy log: ${localbuddyService.logPath}`);
3813
+ const localbuddyService = launchService("localbuddy", [runtimeBinaries.localbuddy]);
3814
+ console.log(`[pushpals] localbuddy log: ${localbuddyService.logPath ?? serviceLogPaths.localbuddy}`);
3573
3815
  recordStartupPhase("localbuddy", localBuddyPhaseStartedAt, "started");
3574
3816
  } else {
3575
3817
  recordStartupPhase("localbuddy", Date.now(), "skipped");
@@ -3578,13 +3820,13 @@ ${tail}` : ""}`);
3578
3820
  }
3579
3821
  const remoteBuddyPhaseStartedAt = Date.now();
3580
3822
  console.log("[pushpals] Starting embedded RemoteBuddy...");
3581
- const remotebuddyService = spawnRuntimeService("remotebuddy", [runtimeBinaries.remotebuddy], opts.repoRoot, runtimeEnv, serviceLogPaths.remotebuddy, runtimeServicesLogPath);
3582
- services.push(remotebuddyService);
3583
- console.log(`[pushpals] remotebuddy log: ${remotebuddyService.logPath}`);
3823
+ const remotebuddyService = launchService("remotebuddy", [runtimeBinaries.remotebuddy]);
3824
+ const remotebuddyLogPath = remotebuddyService.logPath ?? serviceLogPaths.remotebuddy;
3825
+ console.log(`[pushpals] remotebuddy log: ${remotebuddyLogPath}`);
3584
3826
  recordStartupPhase("remotebuddy", remoteBuddyPhaseStartedAt, "started");
3585
3827
  let lastReportedRemoteBuddyAutonomyState = "unknown";
3586
3828
  const reportRemoteBuddyAutonomousEngineState = () => {
3587
- const autonomyState = readRemoteBuddyAutonomousEngineState(remotebuddyService.logPath);
3829
+ const autonomyState = readRemoteBuddyAutonomousEngineState(remotebuddyLogPath);
3588
3830
  if (autonomyState === "unknown" || autonomyState === lastReportedRemoteBuddyAutonomyState) {
3589
3831
  return;
3590
3832
  }
@@ -3635,9 +3877,11 @@ ${tail}` : ""}`);
3635
3877
  } else if (scmRemoteStatus.status === "ok") {
3636
3878
  console.log(`[pushpals] Embedded SourceControlManager git=${scmGitProbe.detail}`);
3637
3879
  console.log("[pushpals] Starting embedded SourceControlManager...");
3638
- const sourceControlManagerService = spawnRuntimeService("source_control_manager", [runtimeBinaries.sourceControlManager, "--skip-clean-check"], opts.repoRoot, runtimeEnv, serviceLogPaths.source_control_manager, runtimeServicesLogPath);
3639
- services.push(sourceControlManagerService);
3640
- console.log(`[pushpals] source_control_manager log: ${sourceControlManagerService.logPath}`);
3880
+ const sourceControlManagerService = launchService("source_control_manager", [
3881
+ runtimeBinaries.sourceControlManager,
3882
+ "--skip-clean-check"
3883
+ ]);
3884
+ console.log(`[pushpals] source_control_manager log: ${sourceControlManagerService.logPath ?? serviceLogPaths.source_control_manager}`);
3641
3885
  recordStartupPhase("source_control_manager", scmPhaseStartedAt, "started");
3642
3886
  } else {
3643
3887
  console.log(`[pushpals] Repo has no git remote "${opts.sourceControlManagerRemote}"; skipping embedded SourceControlManager.`);
@@ -3651,30 +3895,36 @@ ${tail}` : ""}`);
3651
3895
  }
3652
3896
  const deadline = Date.now() + DEFAULT_RUNTIME_BOOT_TIMEOUT_MS;
3653
3897
  const readinessPhaseStartedAt = Date.now();
3898
+ const optionalServiceExitWarned = new Set;
3654
3899
  while (Date.now() < deadline) {
3655
3900
  reportRemoteBuddyAutonomousEngineState();
3656
- for (let i = services.length - 1;i >= 0; i--) {
3657
- const service = services[i];
3901
+ for (const service of serviceManager.getServices()) {
3658
3902
  if (service.exited) {
3659
3903
  if (isOptionalEmbeddedService(service.name)) {
3660
- console.warn(`[pushpals] Embedded ${service.name} exited during startup (code=${service.exitCode ?? "unknown"}); continuing without SCM.`);
3661
- appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] embedded ${service.name} exited during startup (code=${service.exitCode ?? "unknown"}); continuing.`);
3662
- const tail2 = readLogTail(service.logPath);
3663
- if (tail2) {
3664
- console.warn(`[pushpals] ${service.name} log tail:
3904
+ const runtimeServiceName2 = service.name;
3905
+ const serviceLogPath2 = service.logPath ?? serviceLogPaths[runtimeServiceName2];
3906
+ if (!optionalServiceExitWarned.has(runtimeServiceName2)) {
3907
+ console.warn(`[pushpals] Embedded ${service.name} exited during startup (code=${service.exitCode ?? "unknown"}); startup will continue and host supervisor will attempt recovery.`);
3908
+ appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] embedded ${service.name} exited during startup (code=${service.exitCode ?? "unknown"}); startup will continue and host supervisor will attempt recovery.`);
3909
+ const tail2 = readLogTail(serviceLogPath2);
3910
+ if (tail2) {
3911
+ console.warn(`[pushpals] ${service.name} log tail:
3665
3912
  ${tail2}`);
3666
- appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] ${service.name} log tail:
3913
+ appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] ${service.name} log tail:
3667
3914
  ${tail2}`);
3915
+ }
3916
+ optionalServiceExitWarned.add(runtimeServiceName2);
3668
3917
  }
3669
- services.splice(i, 1);
3670
3918
  continue;
3671
3919
  }
3672
- const tail = readLogTail(service.logPath);
3920
+ const runtimeServiceName = service.name;
3921
+ const serviceLogPath = service.logPath ?? serviceLogPaths[runtimeServiceName];
3922
+ const tail = readLogTail(serviceLogPath);
3673
3923
  appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] embedded ${service.name} exited during startup (code=${service.exitCode ?? "unknown"}).`);
3674
3924
  recordStartupPhase("readiness", readinessPhaseStartedAt, "failed");
3675
3925
  emitStartupTimingSummary("failed", `${service.name} exited during startup`);
3676
- stopRuntimeServices(services);
3677
- throw new Error(`Embedded ${service.name} exited during startup (code=${service.exitCode ?? "unknown"}). ` + `See ${service.logPath}${tail ? `
3926
+ stopRuntimeServices(serviceManager.getServices());
3927
+ throw new Error(`Embedded ${service.name} exited during startup (code=${service.exitCode ?? "unknown"}). ` + `See ${serviceLogPath}${tail ? `
3678
3928
  --- ${service.name} log tail ---
3679
3929
  ${tail}` : ""}`);
3680
3930
  }
@@ -3686,29 +3936,34 @@ ${tail}` : ""}`);
3686
3936
  const stabilityDeadline = Date.now() + DEFAULT_SERVICE_STABILITY_GRACE_MS;
3687
3937
  while (Date.now() < stabilityDeadline) {
3688
3938
  reportRemoteBuddyAutonomousEngineState();
3689
- for (let i = services.length - 1;i >= 0; i--) {
3690
- const service = services[i];
3939
+ for (const service of serviceManager.getServices()) {
3691
3940
  if (!service.exited)
3692
3941
  continue;
3693
3942
  if (isOptionalEmbeddedService(service.name)) {
3694
- const tail2 = readLogTail(service.logPath);
3695
- console.warn(`[pushpals] Embedded ${service.name} exited immediately after bootstrap (code=${service.exitCode ?? "unknown"}); continuing without SCM.`);
3696
- appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] embedded ${service.name} exited immediately after bootstrap (code=${service.exitCode ?? "unknown"}); continuing.`);
3697
- if (tail2) {
3698
- console.warn(`[pushpals] ${service.name} log tail:
3943
+ const runtimeServiceName2 = service.name;
3944
+ const serviceLogPath2 = service.logPath ?? serviceLogPaths[runtimeServiceName2];
3945
+ if (!optionalServiceExitWarned.has(runtimeServiceName2)) {
3946
+ const tail2 = readLogTail(serviceLogPath2);
3947
+ console.warn(`[pushpals] Embedded ${service.name} exited immediately after bootstrap (code=${service.exitCode ?? "unknown"}); startup will continue and host supervisor will attempt recovery.`);
3948
+ appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] embedded ${service.name} exited immediately after bootstrap (code=${service.exitCode ?? "unknown"}); startup will continue and host supervisor will attempt recovery.`);
3949
+ if (tail2) {
3950
+ console.warn(`[pushpals] ${service.name} log tail:
3699
3951
  ${tail2}`);
3700
- appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] ${service.name} log tail:
3952
+ appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] ${service.name} log tail:
3701
3953
  ${tail2}`);
3954
+ }
3955
+ optionalServiceExitWarned.add(runtimeServiceName2);
3702
3956
  }
3703
- services.splice(i, 1);
3704
3957
  continue;
3705
3958
  }
3706
- const tail = readLogTail(service.logPath);
3959
+ const runtimeServiceName = service.name;
3960
+ const serviceLogPath = service.logPath ?? serviceLogPaths[runtimeServiceName];
3961
+ const tail = readLogTail(serviceLogPath);
3707
3962
  appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] embedded ${service.name} exited immediately after bootstrap (code=${service.exitCode ?? "unknown"}).`);
3708
3963
  recordStartupPhase("readiness", readinessPhaseStartedAt, "failed");
3709
3964
  emitStartupTimingSummary("failed", `${service.name} exited immediately after bootstrap`);
3710
- stopRuntimeServices(services);
3711
- throw new Error(`Embedded ${service.name} exited immediately after bootstrap (code=${service.exitCode ?? "unknown"}). ` + `See ${service.logPath}${tail ? `
3965
+ stopRuntimeServices(serviceManager.getServices());
3966
+ throw new Error(`Embedded ${service.name} exited immediately after bootstrap (code=${service.exitCode ?? "unknown"}). ` + `See ${serviceLogPath}${tail ? `
3712
3967
  --- ${service.name} log tail ---
3713
3968
  ${tail}` : ""}`);
3714
3969
  }
@@ -3719,13 +3974,13 @@ ${tail}` : ""}`);
3719
3974
  emitStartupTimingSummary("ready");
3720
3975
  appendRuntimeServicesLogLine(runtimeServicesLogPath, "[pushpals] embedded runtime is ready.");
3721
3976
  return {
3722
- services,
3977
+ serviceManager,
3723
3978
  pushpalsLogPath: runtimeServicesLogPath
3724
3979
  };
3725
3980
  }
3726
3981
  await Bun.sleep(DEFAULT_RUNTIME_BOOT_POLL_MS);
3727
3982
  }
3728
- stopRuntimeServices(services);
3983
+ stopRuntimeServices(serviceManager.getServices());
3729
3984
  const remoteBuddyHealth = await probeRemoteBuddySessionConsumer(opts.serverUrl, opts.sessionId);
3730
3985
  if (!localBuddyEnabled && !remoteBuddyHealth.ok) {
3731
3986
  appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] timed out waiting for RemoteBuddy session consumer readiness after ${DEFAULT_RUNTIME_BOOT_TIMEOUT_MS}ms (${remoteBuddyHealth.detail}).`);
@@ -4405,7 +4660,7 @@ async function main() {
4405
4660
  platform: `${process.platform}/${process.arch}`,
4406
4661
  repoRoot
4407
4662
  };
4408
- let autoStartedServices = [];
4663
+ let autoStartedServiceManager = null;
4409
4664
  let pushpalsLogPath;
4410
4665
  let resolvedRuntimeTagForAutoStart = preparedRuntime.runtimeTag || parsed.runtimeTag || "";
4411
4666
  const cleanupWorkerpalWarmContainersIfNeeded = async (phase) => {
@@ -4464,17 +4719,24 @@ async function main() {
4464
4719
  }
4465
4720
  return readiness;
4466
4721
  };
4722
+ const reportEmbeddedRuntimeHealth = () => {
4723
+ const health = autoStartedServiceManager?.getHealth() ?? null;
4724
+ for (const line of formatEmbeddedRuntimeHealthLines2(health)) {
4725
+ console.log(line);
4726
+ }
4727
+ return health;
4728
+ };
4467
4729
  const stopAutoStartedServices = () => {
4468
- if (autoStartedServices.length === 0)
4730
+ if (!autoStartedServiceManager)
4469
4731
  return;
4470
- stopRuntimeServices(autoStartedServices);
4471
- autoStartedServices = [];
4732
+ autoStartedServiceManager.stop();
4733
+ autoStartedServiceManager = null;
4472
4734
  };
4473
4735
  const stopAutoStartedServicesGracefully = async (reason) => {
4474
- if (autoStartedServices.length === 0)
4736
+ if (!autoStartedServiceManager)
4475
4737
  return;
4476
- const services = autoStartedServices;
4477
- autoStartedServices = [];
4738
+ const serviceManager = autoStartedServiceManager;
4739
+ autoStartedServiceManager = null;
4478
4740
  const shutdown = await requestLocalRuntimeShutdown(serverUrl, repoRoot, reason);
4479
4741
  if (shutdown.attempted && shutdown.accepted) {
4480
4742
  console.log("[pushpals] Local runtime shutdown accepted; waiting for services to exit...");
@@ -4484,6 +4746,8 @@ async function main() {
4484
4746
  } else if (shutdown.detail) {
4485
4747
  console.warn(`[pushpals] ${shutdown.detail}`);
4486
4748
  }
4749
+ serviceManager.stop();
4750
+ const services = serviceManager.getServices();
4487
4751
  await stopRuntimeServicesGracefully(services);
4488
4752
  await cleanupWorkerpalWarmContainersIfNeeded("cli shutdown");
4489
4753
  await cleanupPushPalsGitWorktreesIfNeeded("cli shutdown");
@@ -4531,7 +4795,7 @@ async function main() {
4531
4795
  startLocalBuddy: resolveCliLocalBuddyAutostart(parsed.runtimeOnly, Boolean(config.localbuddy.enabled)),
4532
4796
  baseEnv: workerpalDockerPrecheck.env
4533
4797
  });
4534
- autoStartedServices = startedRuntime.services;
4798
+ autoStartedServiceManager = startedRuntime.serviceManager;
4535
4799
  pushpalsLogPath = startedRuntime.pushpalsLogPath;
4536
4800
  serverHealthy = await probeServer(serverUrl);
4537
4801
  } catch (err) {
@@ -4655,6 +4919,7 @@ async function main() {
4655
4919
  console.log(`[pushpals] pushpalsLog=${pushpalsLogPath ?? "unavailable"}`);
4656
4920
  console.log(`[pushpals] cliStateFile=${statePath ?? "unavailable"}`);
4657
4921
  reportWorkerExecutionReadinessFromSnapshot(startupWorkerExecutionReadiness);
4922
+ reportEmbeddedRuntimeHealth();
4658
4923
  if (parsed.runtimeOnly) {
4659
4924
  console.log("[pushpals] runtimeOnly=true");
4660
4925
  } else {
@@ -4689,7 +4954,7 @@ ${line}
4689
4954
  try {
4690
4955
  monitoringHub?.stop();
4691
4956
  } catch {}
4692
- if (autoStartedServices.length > 0) {
4957
+ if (autoStartedServiceManager) {
4693
4958
  console.log("[pushpals] Stopping embedded runtime services...");
4694
4959
  }
4695
4960
  await stopAutoStartedServicesGracefully("pushpals CLI exit");
@@ -4767,6 +5032,7 @@ ${line}
4767
5032
  console.log(`[pushpals] pushpalsLog=${pushpalsLogPath ?? "unavailable"}`);
4768
5033
  console.log(monitoringHubUrl ? `[pushpals] monitoringHubUrl=${monitoringHubUrl}` : "[pushpals] monitoringHubUrl=unavailable");
4769
5034
  await reportWorkerExecutionReadiness();
5035
+ reportEmbeddedRuntimeHealth();
4770
5036
  rl.prompt();
4771
5037
  continue;
4772
5038
  }
@@ -4806,6 +5072,7 @@ export {
4806
5072
  waitForWorkerpalCapacity,
4807
5073
  startEmbeddedMonitoringHub,
4808
5074
  shouldRunEmbeddedRuntimeStartupPrechecks,
5075
+ shouldRestartEmbeddedService,
4809
5076
  resolveWorkerExecutionReadiness,
4810
5077
  resolveWindowsWhereExecutableCandidatesForEnv,
4811
5078
  resolveWindowsShellExecutableCandidatesForEnv,
@@ -4832,6 +5099,7 @@ export {
4832
5099
  formatTimestampedCliLine,
4833
5100
  formatSessionEventLine,
4834
5101
  formatRuntimeStartupTimingSummary,
5102
+ formatEmbeddedRuntimeHealthLines2 as formatEmbeddedRuntimeHealthLines,
4835
5103
  extractRemoteBuddySessionConsumerHealth,
4836
5104
  extractRemoteBuddyAutonomousEngineState,
4837
5105
  ensureWorkerpalDockerImageReady,
@@ -4840,6 +5108,7 @@ export {
4840
5108
  describeWorkerExecutionReadiness,
4841
5109
  createSessionEventReplayFilter,
4842
5110
  copyTrackedRepoPath,
5111
+ computeEmbeddedServiceRestartBackoffMs,
4843
5112
  cleanupLingeringWorkerpalWarmContainers,
4844
5113
  cleanupLingeringPushPalsGitWorktrees,
4845
5114
  bundledMonitoringHubNeedsRefresh,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.0.34",
3
+ "version": "1.0.36",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,6 +45,7 @@
45
45
  "test:prompt-policy": "bun test tests/prompt-policy.enforcement.test.ts",
46
46
  "test:cli:integration": "bun test tests/cli.invocation-logging.test.ts tests/cli.runtime-bootstrap.test.ts tests/client.runtime-bootstrap.test.ts tests/shared.client-preflight.test.ts",
47
47
  "test:cli:e2e": "bun test ./tests/integration/cli.e2e.ts",
48
+ "test:start:e2e": "bun test ./tests/integration/start.e2e.ts",
48
49
  "test:root": "bun test tests",
49
50
  "test:protocol": "bun run tests/protocol.integration.ts",
50
51
  "test:integration": "python -u tests/integration/integration_controller.py --mode integration",