@pushpalsdev/cli 1.1.39 → 1.1.41

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.
@@ -58,11 +58,13 @@ function shouldRestartService(attempts, maxAttempts = DEFAULT_SERVICE_MANAGER_MA
58
58
  return normalizedAttempts < normalizedMax;
59
59
  }
60
60
  function pipeProcessStreamToLines(stream, onLine) {
61
- if (!stream || typeof stream === "number" || typeof stream.getReader !== "function")
62
- return;
61
+ if (!stream || typeof stream === "number" || typeof stream.getReader !== "function") {
62
+ return { cancel: () => {} };
63
+ }
63
64
  const reader = stream.getReader();
64
65
  const decoder = new TextDecoder;
65
66
  let pending = "";
67
+ let cancelled = false;
66
68
  (async () => {
67
69
  try {
68
70
  while (true) {
@@ -86,9 +88,21 @@ function pipeProcessStreamToLines(stream, onLine) {
86
88
  if (tail)
87
89
  onLine?.(tail);
88
90
  } catch {} finally {
89
- reader.releaseLock();
91
+ try {
92
+ reader.releaseLock();
93
+ } catch {}
90
94
  }
91
95
  })();
96
+ return {
97
+ cancel: () => {
98
+ if (cancelled)
99
+ return;
100
+ cancelled = true;
101
+ try {
102
+ reader.cancel().catch(() => {});
103
+ } catch {}
104
+ }
105
+ };
92
106
  }
93
107
  function spawnManagedService(spec) {
94
108
  const env = { ...spec.env ?? {} };
@@ -98,8 +112,8 @@ function spawnManagedService(spec) {
98
112
  stdout: "pipe",
99
113
  stderr: "pipe"
100
114
  });
101
- pipeProcessStreamToLines(proc.stdout, spec.onStdoutLine);
102
- pipeProcessStreamToLines(proc.stderr, spec.onStderrLine);
115
+ const stdoutPipe = pipeProcessStreamToLines(proc.stdout, spec.onStdoutLine);
116
+ const stderrPipe = pipeProcessStreamToLines(proc.stderr, spec.onStderrLine);
103
117
  const service = {
104
118
  name: spec.name,
105
119
  proc,
@@ -109,7 +123,11 @@ function spawnManagedService(spec) {
109
123
  exited: false,
110
124
  exitCode: null,
111
125
  launchedAtMs: Date.now(),
112
- logPath: spec.logPath
126
+ logPath: spec.logPath,
127
+ stopOutputPipes: () => {
128
+ stdoutPipe.cancel();
129
+ stderrPipe.cancel();
130
+ }
113
131
  };
114
132
  proc.exited.then((code) => {
115
133
  service.exited = true;
@@ -161,6 +179,7 @@ class ServiceManager {
161
179
  const existing = this.services.get(spec.name);
162
180
  if (existing && !existing.exited) {
163
181
  try {
182
+ existing.stopOutputPipes?.();
164
183
  const pid = existing.proc.pid;
165
184
  if (process.platform === "win32" && typeof pid === "number" && pid > 0) {
166
185
  Bun.spawnSync(["taskkill", "/PID", String(pid), "/T", "/F"], {
@@ -224,6 +243,7 @@ class ServiceManager {
224
243
  this.stopped = true;
225
244
  for (const service of this.services.values()) {
226
245
  try {
246
+ service.stopOutputPipes?.();
227
247
  const pid = service.proc.pid;
228
248
  if (process.platform === "win32" && typeof pid === "number" && pid > 0) {
229
249
  Bun.spawnSync(["taskkill", "/PID", String(pid), "/T", "/F"], {
@@ -3221,6 +3241,7 @@ function buildServiceStopCommand(pid, platform = process.platform) {
3221
3241
  function stopRuntimeServices(services) {
3222
3242
  for (const service of services) {
3223
3243
  try {
3244
+ service.stopOutputPipes?.();
3224
3245
  const stopCommand = buildServiceStopCommand(service.proc.pid, process.platform);
3225
3246
  if (stopCommand) {
3226
3247
  Bun.spawnSync(stopCommand, {
@@ -3236,6 +3257,52 @@ function stopRuntimeServices(services) {
3236
3257
  } catch {}
3237
3258
  }
3238
3259
  }
3260
+ async function runWindowsServiceStopCommand(command, timeoutMs) {
3261
+ const proc = Bun.spawn(command, {
3262
+ stdin: "ignore",
3263
+ stdout: "ignore",
3264
+ stderr: "ignore"
3265
+ });
3266
+ let timeout = null;
3267
+ let timedOut = false;
3268
+ const exitCode = await Promise.race([
3269
+ proc.exited,
3270
+ new Promise((resolveTimeout) => {
3271
+ timeout = setTimeout(() => {
3272
+ timedOut = true;
3273
+ try {
3274
+ proc.kill("SIGKILL");
3275
+ } catch {}
3276
+ resolveTimeout(-1);
3277
+ }, Math.max(250, timeoutMs));
3278
+ })
3279
+ ]);
3280
+ if (timeout) {
3281
+ clearTimeout(timeout);
3282
+ timeout = null;
3283
+ }
3284
+ if (timedOut) {
3285
+ await Promise.race([proc.exited.catch(() => -1), Bun.sleep(250)]);
3286
+ return false;
3287
+ }
3288
+ return exitCode === 0;
3289
+ }
3290
+ async function stopRuntimeServicesOnWindows(services, timeoutMs) {
3291
+ const deadline = Date.now() + Math.max(1000, timeoutMs);
3292
+ for (const service of services) {
3293
+ service.stopOutputPipes?.();
3294
+ const stopCommand = buildServiceStopCommand(service.proc.pid, "win32");
3295
+ if (stopCommand) {
3296
+ const remainingMs = Math.max(250, deadline - Date.now());
3297
+ const stopped = await runWindowsServiceStopCommand(stopCommand, Math.min(WINDOWS_TASKKILL_TIMEOUT_MS2, remainingMs));
3298
+ if (stopped)
3299
+ continue;
3300
+ }
3301
+ try {
3302
+ service.proc.kill("SIGKILL");
3303
+ } catch {}
3304
+ }
3305
+ }
3239
3306
  function resolveGracefulShutdownPriority(name) {
3240
3307
  if (name === "source_control_manager")
3241
3308
  return 0;
@@ -3264,7 +3331,7 @@ async function stopRuntimeServicesGracefully(services, timeoutMs = 1e4) {
3264
3331
  return;
3265
3332
  const ordered = [...running].sort((a, b) => resolveGracefulShutdownPriority(a.name) - resolveGracefulShutdownPriority(b.name));
3266
3333
  if (process.platform === "win32") {
3267
- stopRuntimeServices(ordered);
3334
+ await stopRuntimeServicesOnWindows(ordered, timeoutMs);
3268
3335
  await waitForRuntimeServicesExit(ordered, Math.min(1000, timeoutMs));
3269
3336
  return;
3270
3337
  }
@@ -3295,6 +3362,7 @@ async function shutdownEmbeddedServiceManagerGracefully(options) {
3295
3362
  reason,
3296
3363
  requestShutdown = requestLocalRuntimeShutdown,
3297
3364
  shutdownAcceptedDelayMs = 1500,
3365
+ serviceStopTimeoutMs = 1e4,
3298
3366
  onLog = (line) => console.log(line),
3299
3367
  onWarn = (line) => console.warn(line),
3300
3368
  cleanupTasks = []
@@ -3310,7 +3378,7 @@ async function shutdownEmbeddedServiceManagerGracefully(options) {
3310
3378
  } else if (shutdown.detail) {
3311
3379
  onWarn(`[pushpals] ${shutdown.detail}`);
3312
3380
  }
3313
- await stopRuntimeServicesGracefully(services);
3381
+ await stopRuntimeServicesGracefully(services, serviceStopTimeoutMs);
3314
3382
  for (const task of cleanupTasks) {
3315
3383
  await task();
3316
3384
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.1.39",
3
+ "version": "1.1.41",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -119,6 +119,7 @@ _DEFAULT_NO_EDIT_RECHECK_S = 120
119
119
  _NO_EDIT_RECOVERY_RECHECK_S = 30
120
120
  _DEFAULT_NO_EDIT_COMMAND_GRACE_S = 240
121
121
  _DEFAULT_NO_EDIT_COMMAND_PROGRESS_CAP_S = 360
122
+ _BACKGROUND_NO_EDIT_COMMAND_PROGRESS_CAP_S = 120
122
123
  _NO_EDIT_RECOVERY_COMMAND_PROGRESS_CAP_S = 120
123
124
  _DEFAULT_STARTUP_STALL_WATCHDOG_S = 210
124
125
  _RECOVERY_STARTUP_STALL_WATCHDOG_S = 150
@@ -817,6 +818,7 @@ def _resolve_no_edit_command_progress_cap_seconds(
817
818
  communicate_timeout_s: Optional[int],
818
819
  no_edit_command_grace_s: Optional[int],
819
820
  recovery_attempt: int = 0,
821
+ prompt: str = "",
820
822
  ) -> Optional[int]:
821
823
  if not communicate_timeout_s or no_edit_command_grace_s is None:
822
824
  return None
@@ -834,11 +836,12 @@ def _resolve_no_edit_command_progress_cap_seconds(
834
836
  else:
835
837
  return max(1, min(parsed, max(1, communicate_timeout_s - 1)))
836
838
 
837
- default_s = (
838
- _NO_EDIT_RECOVERY_COMMAND_PROGRESS_CAP_S
839
- if recovery_attempt > 0
840
- else _DEFAULT_NO_EDIT_COMMAND_PROGRESS_CAP_S
841
- )
839
+ if recovery_attempt > 0:
840
+ default_s = _NO_EDIT_RECOVERY_COMMAND_PROGRESS_CAP_S
841
+ elif _looks_like_background_autonomy_prompt(prompt):
842
+ default_s = _BACKGROUND_NO_EDIT_COMMAND_PROGRESS_CAP_S
843
+ else:
844
+ default_s = _DEFAULT_NO_EDIT_COMMAND_PROGRESS_CAP_S
842
845
  upper = max(1, communicate_timeout_s - 1)
843
846
  return max(1, min(default_s, upper))
844
847
 
@@ -2713,6 +2716,7 @@ def _run_codex_task(
2713
2716
  communicate_timeout_s,
2714
2717
  no_edit_command_grace_s,
2715
2718
  recovery_attempt=recovery_depth,
2719
+ prompt=prompt,
2716
2720
  )
2717
2721
  startup_stall_watchdog_s = _resolve_startup_stall_watchdog_seconds(
2718
2722
  communicate_timeout_s,
@@ -45,6 +45,7 @@ from openai_codex_executor import (
45
45
  _has_credible_shell_wrapper_progress,
46
46
  _load_prompt_template,
47
47
  _mask_repo_local_codex_files,
48
+ _minimum_recovery_attempt_seconds,
48
49
  _repo_root_for_prompt_loading,
49
50
  _restore_repo_local_codex_files,
50
51
  _resolve_codex_command_prefix,
@@ -249,6 +250,41 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
249
250
  )
250
251
  self.assertEqual(_resolve_rollout_watchdog_seconds(prompt, 1200, no_edit), 90)
251
252
 
253
+ def test_background_autonomy_caps_patchless_command_progress_before_recovery_reserve(self) -> None:
254
+ prompt = (
255
+ "Task planning contract from PushPals:\n"
256
+ "- Planning summary: intent=code_change, risk=low, priority=background\n"
257
+ "- Origin=autonomy targetPaths=[app/__tests__/opportunity-graph.contract.test.ts]\n"
258
+ "Add focused contract coverage without broad discovery.\n"
259
+ )
260
+ child_budget_s = 570
261
+
262
+ with mock.patch.dict(
263
+ os.environ,
264
+ {
265
+ "WORKERPALS_OPENAI_CODEX_NO_EDIT_COMMAND_GRACE_S": "",
266
+ "WORKERPALS_OPENAI_CODEX_NO_EDIT_COMMAND_PROGRESS_CAP_S": "",
267
+ "WORKERPALS_OPENAI_CODEX_STARTUP_STALL_WATCHDOG_S": "",
268
+ },
269
+ clear=False,
270
+ ):
271
+ command_grace_s = _resolve_no_edit_command_grace_seconds(child_budget_s)
272
+ command_cap_s = _resolve_no_edit_command_progress_cap_seconds(
273
+ child_budget_s,
274
+ command_grace_s,
275
+ prompt=prompt,
276
+ )
277
+ startup_stall_s = _resolve_startup_stall_watchdog_seconds(child_budget_s)
278
+
279
+ self.assertEqual(command_grace_s, 240)
280
+ self.assertEqual(command_cap_s, 120)
281
+ self.assertEqual(startup_stall_s, 210)
282
+ first_attempt_patchless_ceiling_s = startup_stall_s + command_cap_s
283
+ self.assertGreaterEqual(
284
+ child_budget_s - first_attempt_patchless_ceiling_s,
285
+ 2 * _minimum_recovery_attempt_seconds(child_budget_s),
286
+ )
287
+
252
288
  def test_runtime_config_prefers_explicit_config_dir_override(self) -> None:
253
289
  import executor_base
254
290