@pushpalsdev/cli 1.1.14 → 1.1.16

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.
@@ -1637,6 +1637,7 @@ var DEFAULT_RUNTIME_BOOT_TIMEOUT_MS = 90000;
1637
1637
  var DEFAULT_RUNTIME_BOOT_POLL_MS = 1000;
1638
1638
  var DEFAULT_SERVER_BOOT_TIMEOUT_MS = 20000;
1639
1639
  var DEFAULT_SERVICE_STABILITY_GRACE_MS = 4000;
1640
+ var DEFAULT_REMOTEBUDDY_CONSUMER_STARTUP_GRACE_MS = 8000;
1640
1641
  var DEFAULT_COMMAND_OUTPUT_DRAIN_TIMEOUT_MS = 2000;
1641
1642
  var DEFAULT_COMMAND_OUTPUT_MAX_CHARS = 512000;
1642
1643
  var DEFAULT_REMOTEBUDDY_SILENT_STARTUP_FALLBACK_MS = 20000;
@@ -1648,6 +1649,7 @@ var DEFAULT_EMBEDDED_SERVICE_LAUNCH_WARN_MS = 5000;
1648
1649
  var EMBEDDED_SERVICE_RESTART_MAX_ATTEMPTS = 4;
1649
1650
  var WORKERPAL_STARTUP_READINESS_PROBE_MAX_MS = 15000;
1650
1651
  var CLI_SESSION_JOB_LOG_MAX_CHARS = 700;
1652
+ var CLI_SESSION_SHOW_JOB_EVENTS_ENV = "PUSHPALS_CLI_SHOW_JOB_EVENTS";
1651
1653
  var EMBEDDED_RUNTIME_SAFETY_CAP_DISABLE_ENV = "PUSHPALS_DISABLE_EMBEDDED_SAFETY_CAPS";
1652
1654
  var EMBEDDED_RUNTIME_WINDOWS_SAFETY_CAPS = {
1653
1655
  REMOTEBUDDY_WORKERPAL_STARTUP_TIMEOUT_MS: "120000",
@@ -1674,6 +1676,12 @@ function formatTimestampedCliLine(line, at = new Date) {
1674
1676
  }
1675
1677
  return `[${at.toISOString()}]${text}`;
1676
1678
  }
1679
+ function isTruthyCliEnvValue(value) {
1680
+ return /^(1|true|yes|on)$/i.test(String(value ?? "").trim());
1681
+ }
1682
+ function shouldShowCliSessionOperationalEvents(env = process.env) {
1683
+ return isTruthyCliEnvValue(env[CLI_SESSION_SHOW_JOB_EVENTS_ENV]);
1684
+ }
1677
1685
  function formatRuntimeStartupTimingSummary(input) {
1678
1686
  const phaseSummary = input.phases.map((phase) => `${phase.name}=${Math.max(0, Math.floor(phase.durationMs))}ms(${phase.status.trim() || "unknown"})`).join(" ");
1679
1687
  const detail = typeof input.detail === "string" && input.detail.trim() ? ` detail=${input.detail.trim()}` : "";
@@ -3443,7 +3451,7 @@ function isWorkerpalEphemeralWorktreePath(repoRoot, worktreePath) {
3443
3451
  if (!normalizedPath.startsWith(expectedPrefix))
3444
3452
  return false;
3445
3453
  const leaf = basename(normalizedPath);
3446
- return /^(job|selfcheck)-.*-workerpal-[a-z0-9._-]+/i.test(leaf);
3454
+ return /^(job|selfcheck)-[a-z0-9][a-z0-9._-]*$/i.test(leaf);
3447
3455
  }
3448
3456
  function resolveConfiguredDockerExecutable(env, platform = process.platform) {
3449
3457
  const configured = String(env.PUSHPALS_DOCKER_BIN_ABSOLUTE ?? env.PUSHPALS_DOCKER_BIN ?? (platform === "win32" ? "docker.exe" : "docker")).trim();
@@ -4241,6 +4249,16 @@ function extractRemoteBuddySessionConsumerHealth(statusPayload, sessionId) {
4241
4249
  detail: `No connected RemoteBuddy session consumer found for session ${sessionId}`
4242
4250
  };
4243
4251
  }
4252
+ function shouldDeferRemoteBuddySessionConsumerReadiness(opts) {
4253
+ if (opts.localBuddyEnabled)
4254
+ return false;
4255
+ if (opts.remoteBuddyReady)
4256
+ return false;
4257
+ if (!opts.remoteBuddyServiceRunning)
4258
+ return false;
4259
+ const startupGraceMs = Math.max(0, opts.startupGraceMs ?? DEFAULT_REMOTEBUDDY_CONSUMER_STARTUP_GRACE_MS);
4260
+ return opts.readinessElapsedMs >= startupGraceMs;
4261
+ }
4244
4262
  async function probeRemoteBuddySessionConsumer(serverUrl, sessionId) {
4245
4263
  try {
4246
4264
  const response = await fetchWithTimeout(`${serverUrl}/system/status`, {}, 1e4);
@@ -4709,6 +4727,7 @@ ${tail}` : ""}`);
4709
4727
  const optionalServiceExitWarned = new Set;
4710
4728
  let lastReadinessWaitLogAt = 0;
4711
4729
  let lastReadinessWaitDetail = "";
4730
+ let deferredRemoteBuddyConsumerLogged = false;
4712
4731
  while (Date.now() < deadline) {
4713
4732
  reportRemoteBuddyAutonomousEngineState();
4714
4733
  if (maybeActivateRemoteBuddyWindowsFallback("silent_startup")) {
@@ -4752,7 +4771,15 @@ ${tail}` : ""}`);
4752
4771
  }
4753
4772
  const health = localBuddyEnabled ? await probeLocalBuddy(opts.localAgentUrl) : null;
4754
4773
  const remoteBuddyHealth2 = await probeRemoteBuddySessionConsumer(opts.serverUrl, opts.sessionId);
4755
- if (localBuddyEnabled && !health?.ok || !remoteBuddyHealth2.ok) {
4774
+ const remoteBuddyServiceRunning = serviceManager.getServices().some((service) => service.name === "remotebuddy" && !service.exited);
4775
+ const deferRemoteBuddyConsumer = shouldDeferRemoteBuddySessionConsumerReadiness({
4776
+ localBuddyEnabled,
4777
+ remoteBuddyReady: remoteBuddyHealth2.ok,
4778
+ remoteBuddyServiceRunning,
4779
+ readinessElapsedMs: Date.now() - readinessPhaseStartedAt
4780
+ });
4781
+ const remoteBuddyReadyForCli = remoteBuddyHealth2.ok || deferRemoteBuddyConsumer;
4782
+ if (localBuddyEnabled && !health?.ok || !remoteBuddyReadyForCli) {
4756
4783
  const localBuddyDetail = localBuddyEnabled ? health?.ok ? "LocalBuddy ready" : "LocalBuddy not ready" : "LocalBuddy skipped";
4757
4784
  const readinessDetail = `${localBuddyDetail}; ${remoteBuddyHealth2.detail}`;
4758
4785
  const now = Date.now();
@@ -4763,7 +4790,11 @@ ${tail}` : ""}`);
4763
4790
  lastReadinessWaitLogAt = now;
4764
4791
  }
4765
4792
  }
4766
- if ((!localBuddyEnabled || health?.ok) && remoteBuddyHealth2.ok) {
4793
+ if ((!localBuddyEnabled || health?.ok) && remoteBuddyReadyForCli) {
4794
+ if (deferRemoteBuddyConsumer && !deferredRemoteBuddyConsumerLogged) {
4795
+ appendRuntimeServicesLogLine(runtimeServicesLogPath, `[pushpals] continuing startup after ${Date.now() - readinessPhaseStartedAt}ms without a connected RemoteBuddy session consumer; embedded RemoteBuddy is running and the CLI session will connect after startup (${remoteBuddyHealth2.detail}).`);
4796
+ deferredRemoteBuddyConsumerLogged = true;
4797
+ }
4767
4798
  reportRemoteBuddyAutonomousEngineState();
4768
4799
  const stabilityDeadline = Date.now() + DEFAULT_SERVICE_STABILITY_GRACE_MS;
4769
4800
  while (Date.now() < stabilityDeadline) {
@@ -5218,7 +5249,10 @@ function formatSessionEventLine(event) {
5218
5249
  const type = String(event.type ?? "").toLowerCase();
5219
5250
  const from = String(event.from ?? "");
5220
5251
  const payload = event.payload ?? {};
5252
+ const showOperationalEvents = shouldShowCliSessionOperationalEvents();
5221
5253
  if (type === "job_enqueued") {
5254
+ if (!showOperationalEvents)
5255
+ return null;
5222
5256
  const jobId = String(payload.jobId ?? "").slice(0, 8);
5223
5257
  const kind = String(payload.kind ?? "").trim();
5224
5258
  const taskId = String(payload.taskId ?? "").slice(0, 8);
@@ -5226,11 +5260,15 @@ function formatSessionEventLine(event) {
5226
5260
  return `[job ${jobId}] queued: ${detail}`;
5227
5261
  }
5228
5262
  if (type === "job_claimed") {
5263
+ if (!showOperationalEvents)
5264
+ return null;
5229
5265
  const jobId = String(payload.jobId ?? "").slice(0, 8);
5230
5266
  const workerId = String(payload.workerId ?? "").trim();
5231
5267
  return `[job ${jobId}] claimed${workerId ? ` by ${workerId}` : ""}`;
5232
5268
  }
5233
5269
  if (type === "job_log") {
5270
+ if (!showOperationalEvents)
5271
+ return null;
5234
5272
  const jobId = String(payload.jobId ?? "").slice(0, 8);
5235
5273
  const stream = String(payload.stream ?? "").toLowerCase() === "stderr" ? " stderr" : "";
5236
5274
  const phase = compactCliSessionJobLogLine(String(payload.phase ?? "").trim());
@@ -5239,6 +5277,8 @@ function formatSessionEventLine(event) {
5239
5277
  return line ? `[job ${jobId}${stream}${phaseLabel}] ${line}` : null;
5240
5278
  }
5241
5279
  if (type === "job_failed") {
5280
+ if (!showOperationalEvents)
5281
+ return null;
5242
5282
  const jobId = String(payload.jobId ?? "").slice(0, 8);
5243
5283
  const message = String(payload.message ?? "").trim();
5244
5284
  return `[job ${jobId}] failed: ${message || "unknown"}`;
@@ -5251,24 +5291,34 @@ function formatSessionEventLine(event) {
5251
5291
  const text = String(payload.text ?? "").trim();
5252
5292
  if (!text)
5253
5293
  return null;
5294
+ if (/^All systems online\b/i.test(text))
5295
+ return null;
5254
5296
  return `assistant> ${text}`;
5255
5297
  }
5256
5298
  if (type === "task_progress") {
5299
+ if (!showOperationalEvents)
5300
+ return null;
5257
5301
  const taskId = String(payload.taskId ?? "").slice(0, 8);
5258
5302
  const message = String(payload.message ?? "").trim();
5259
5303
  return message ? `[task ${taskId}] ${message}` : null;
5260
5304
  }
5261
5305
  if (type === "task_failed") {
5306
+ if (!showOperationalEvents)
5307
+ return null;
5262
5308
  const taskId = String(payload.taskId ?? "").slice(0, 8);
5263
5309
  const message = String(payload.message ?? "").trim();
5264
5310
  return `[task ${taskId}] failed: ${message || "unknown"}`;
5265
5311
  }
5266
5312
  if (type === "task_completed") {
5313
+ if (!showOperationalEvents)
5314
+ return null;
5267
5315
  const taskId = String(payload.taskId ?? "").slice(0, 8);
5268
5316
  const summary = String(payload.summary ?? "").trim();
5269
5317
  return `[task ${taskId}] completed${summary ? `: ${summary}` : ""}`;
5270
5318
  }
5271
5319
  if (type === "job_completed") {
5320
+ if (!showOperationalEvents)
5321
+ return null;
5272
5322
  const jobId = String(payload.jobId ?? "").slice(0, 8);
5273
5323
  const summary = String(payload.summary ?? "").trim();
5274
5324
  return `[job ${jobId}] completed${summary ? `: ${summary}` : ""}`;
@@ -5277,6 +5327,8 @@ function formatSessionEventLine(event) {
5277
5327
  return null;
5278
5328
  }
5279
5329
  if (type === "status") {
5330
+ if (!showOperationalEvents)
5331
+ return null;
5280
5332
  const state = String(payload.state ?? "").trim();
5281
5333
  const detail = String(payload.detail ?? "").trim();
5282
5334
  const source = from || String(payload.agentId ?? "status");
@@ -6009,8 +6061,10 @@ export {
6009
6061
  startEmbeddedMonitoringHub,
6010
6062
  shutdownEmbeddedServiceManagerGracefully,
6011
6063
  shouldUseRemoteBuddySilentStartupFallback,
6064
+ shouldShowCliSessionOperationalEvents,
6012
6065
  shouldRunEmbeddedRuntimeStartupPrechecks,
6013
6066
  shouldRestartEmbeddedService,
6067
+ shouldDeferRemoteBuddySessionConsumerReadiness,
6014
6068
  runCommandWithEnv,
6015
6069
  resolveWorkerpalDockerProbe,
6016
6070
  resolveWorkerExecutionReadiness,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.1.14",
3
+ "version": "1.1.16",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -4276,6 +4276,9 @@ var IDEATION_SYSTEM_PROMPT = loadPromptTemplate("remotebuddy/autonomy_ideation_s
4276
4276
  var SCORING_SYSTEM_PROMPT = loadPromptTemplate("remotebuddy/autonomy_scoring_system_prompt.md").trim();
4277
4277
  var PLANNING_SYSTEM_PROMPT = loadPromptTemplate("remotebuddy/autonomy_planning_system_prompt.md").trim();
4278
4278
  var IDEATION_TIMEOUT_RECOVERY_INSTRUCTION = "Previous ideation timed out before you returned JSON. For this round only, stay within the time budget: prioritize the top 1-3 highest-confidence candidates, keep reasoning brief, avoid exhaustive exploration, and return valid JSON as soon as possible.";
4279
+ var IDEATION_NORMAL_MAX_TOKENS = 1800;
4280
+ var IDEATION_RETRY_MAX_TOKENS = 900;
4281
+ var IDEATION_NORMAL_MAX_CANDIDATES = 5;
4279
4282
  var STARTUP_FAST_TICK_MAX_ATTEMPTS = 4;
4280
4283
  var STARTUP_FAST_TICK_MAX_DELAY_MS = 15000;
4281
4284
  var STARTUP_STALE_LOCK_AFTER_MS = 30000;
@@ -7371,17 +7374,17 @@ ${JSON.stringify(input.messages ?? [])}`),
7371
7374
  this.setPhase("ideation");
7372
7375
  const buildIdeationInput = (ideationRecovery2, compactRetry) => {
7373
7376
  const reduced = compactRetry || Boolean(ideationRecovery2);
7374
- const ideationTopSignals = snapshot.top_signals.slice(0, reduced ? 5 : 16);
7375
- const ideationStateTraits = snapshot.state_traits.slice(0, reduced ? 6 : 24);
7376
- const ideationFeedbackPriors = snapshot.feedback_priors.slice(0, reduced ? 4 : 20);
7377
- const ideationEngineIdeaPriors = (snapshot.engine_idea_priors ?? []).slice(0, reduced ? 4 : 20);
7378
- const ideationOpenObjectives = snapshot.open_objectives.slice(0, reduced ? 4 : 20);
7379
- const ideationActiveCooldowns = snapshot.active_cooldowns.slice(0, reduced ? 4 : 20);
7380
- const ideationRepoTargets = repoTargets.slice(0, reduced ? 4 : repoTargets.length);
7377
+ const ideationTopSignals = snapshot.top_signals.slice(0, reduced ? 5 : 10);
7378
+ const ideationStateTraits = snapshot.state_traits.slice(0, reduced ? 6 : 12);
7379
+ const ideationFeedbackPriors = snapshot.feedback_priors.slice(0, reduced ? 4 : 8);
7380
+ const ideationEngineIdeaPriors = (snapshot.engine_idea_priors ?? []).slice(0, reduced ? 4 : 8);
7381
+ const ideationOpenObjectives = snapshot.open_objectives.slice(0, reduced ? 4 : 8);
7382
+ const ideationActiveCooldowns = snapshot.active_cooldowns.slice(0, reduced ? 4 : 8);
7383
+ const ideationRepoTargets = repoTargets.slice(0, reduced ? 4 : 8);
7381
7384
  return {
7382
7385
  system: IDEATION_SYSTEM_PROMPT,
7383
7386
  json: true,
7384
- maxTokens: reduced ? 900 : 2800,
7387
+ maxTokens: reduced ? IDEATION_RETRY_MAX_TOKENS : IDEATION_NORMAL_MAX_TOKENS,
7385
7388
  temperature: 0.2,
7386
7389
  messages: [
7387
7390
  ...ideationRecovery2 ? [
@@ -7402,7 +7405,7 @@ ${JSON.stringify(input.messages ?? [])}`),
7402
7405
  open_objectives: ideationOpenObjectives,
7403
7406
  active_cooldowns: ideationActiveCooldowns
7404
7407
  },
7405
- vision: reduced ? compactVisionContextForIdeationRetry(visionContext) : visionContext,
7408
+ vision: compactVisionContextForIdeationRetry(visionContext),
7406
7409
  repo_targets: ideationRepoTargets.map((target) => ({
7407
7410
  component_area: target.component_area,
7408
7411
  target_paths: target.target_paths,
@@ -7410,12 +7413,12 @@ ${JSON.stringify(input.messages ?? [])}`),
7410
7413
  label: target.label,
7411
7414
  keywords: target.keywords.slice(0, reduced ? 4 : 8)
7412
7415
  })),
7413
- engine_inspiration: reduced ? compactEngineInspirationForIdeationRetry(engineInspiration) : engineInspiration,
7416
+ engine_inspiration: compactEngineInspirationForIdeationRetry(engineInspiration),
7414
7417
  limits: {
7415
- ideation_max_candidates: reduced ? Math.max(1, Math.min(3, this.cfg.ideationMaxCandidates)) : this.cfg.ideationMaxCandidates,
7418
+ ideation_max_candidates: reduced ? Math.max(1, Math.min(3, this.cfg.ideationMaxCandidates)) : Math.max(1, Math.min(IDEATION_NORMAL_MAX_CANDIDATES, this.cfg.ideationMaxCandidates)),
7416
7419
  min_confidence: this.cfg.minConfidence
7417
7420
  }
7418
- }, null, reduced ? 0 : 2)
7421
+ }, null, 0)
7419
7422
  }
7420
7423
  ]
7421
7424
  };
@@ -204,6 +204,50 @@ function formatGenericPythonExecutorTimeoutDetail(
204
204
  return `${configPath}=${configuredTimeoutMs}ms within planning executionBudgetMs=${executionBudgetMs}ms`;
205
205
  }
206
206
 
207
+ export function normalizeGenericPythonExecutorParsedResultForTimeout(params: {
208
+ backendName: string;
209
+ kind: string;
210
+ timedOut: boolean;
211
+ timeoutMs: number;
212
+ timeoutDetail?: string;
213
+ summary: string;
214
+ stdout: string;
215
+ stderr: string;
216
+ exitCode: number;
217
+ }): { summary: string; stdout: string; stderr: string; exitCode: number } {
218
+ const signalTerminatedCodex =
219
+ params.timedOut &&
220
+ params.backendName === "openai_codex" &&
221
+ /\bopenai_codex interrupted by signal 15\b/i.test(params.summary);
222
+ if (!signalTerminatedCodex) {
223
+ return {
224
+ summary: params.summary,
225
+ stdout: params.stdout,
226
+ stderr: params.stderr,
227
+ exitCode: params.exitCode,
228
+ };
229
+ }
230
+
231
+ const timeoutDetail = String(params.timeoutDetail ?? "").trim();
232
+ const cleanedStderr = String(params.stderr ?? "")
233
+ .replace(/\bopenai_codex interrupted by signal 15\b/gi, "OpenAI Codex exceeded the execution budget")
234
+ .trim();
235
+ const stderr = [
236
+ `OpenAI Codex exceeded the PushPals execution budget before returning a completed result.`,
237
+ timeoutDetail ? `Timeout detail: ${timeoutDetail}.` : "",
238
+ cleanedStderr ? `Last stderr:\n${cleanedStderr}` : "",
239
+ ]
240
+ .filter(Boolean)
241
+ .join("\n");
242
+
243
+ return {
244
+ summary: `${params.backendName} execution budget expired after ${params.timeoutMs}ms for ${params.kind}`,
245
+ stdout: params.stdout,
246
+ stderr,
247
+ exitCode: 124,
248
+ };
249
+ }
250
+
207
251
  export function createGenericPythonExecutor(
208
252
  config: GenericPythonExecutorConfig,
209
253
  ): BackendTaskExecutor {
@@ -390,16 +434,27 @@ export function createGenericPythonExecutor(
390
434
  parsed.usage,
391
435
  estimateJobTokenUsage(backendName, modelId, params, summary, parsedStdout, parsedStderr),
392
436
  );
393
-
394
- return {
395
- ok: typeof parsed.ok === "boolean" ? parsed.ok : exitCode === 0,
437
+ const normalized = normalizeGenericPythonExecutorParsedResultForTimeout({
438
+ backendName,
439
+ kind,
440
+ timedOut,
441
+ timeoutMs,
442
+ timeoutDetail,
396
443
  summary,
397
- stdout: truncate(parsedStdout, outputPolicy),
398
- stderr: truncate(parsedStderr, outputPolicy),
444
+ stdout: parsedStdout,
445
+ stderr: parsedStderr,
399
446
  exitCode:
400
447
  typeof parsed.exitCode === "number" && Number.isFinite(parsed.exitCode)
401
448
  ? parsed.exitCode
402
449
  : exitCode,
450
+ });
451
+
452
+ return {
453
+ ok: typeof parsed.ok === "boolean" ? parsed.ok : exitCode === 0,
454
+ summary: normalized.summary,
455
+ stdout: truncate(normalized.stdout, outputPolicy),
456
+ stderr: truncate(normalized.stderr, outputPolicy),
457
+ exitCode: normalized.exitCode,
403
458
  usage,
404
459
  };
405
460
  } catch (err) {
@@ -428,13 +428,20 @@ export class DockerExecutor {
428
428
 
429
429
  // Step 3: Run Docker container with the worktree mounted
430
430
  for (let attempt = 1; attempt <= this.jobRetryMaxAttempts; attempt++) {
431
+ const attemptStartedAtMs = Date.now();
431
432
  try {
432
433
  this.logExecutionConfig();
433
434
  const result = await this.runInWarmContainer(worktreePath, base64Spec, job, onLog);
434
435
  if (result.ok) return result;
435
436
 
436
437
  const retryableFailure = this.isRetryableJobFailure(result);
437
- if (attempt >= this.jobRetryMaxAttempts || !retryableFailure) {
438
+ const attemptElapsedMs = Math.max(1, Date.now() - attemptStartedAtMs);
439
+ const timeoutMs = resolveDockerJobTimeoutMs(this.options.timeoutMs, job);
440
+ const hasBudgetForRetry =
441
+ retryableFailure &&
442
+ attempt < this.jobRetryMaxAttempts &&
443
+ this.hasBudgetForJobRetry(attempt, attemptElapsedMs, timeoutMs, onLog);
444
+ if (attempt >= this.jobRetryMaxAttempts || !retryableFailure || !hasBudgetForRetry) {
438
445
  if (
439
446
  retryableFailure &&
440
447
  attempt >= this.jobRetryMaxAttempts &&
@@ -458,7 +465,13 @@ export class DockerExecutor {
458
465
  await this.sleep(retryInMs);
459
466
  } catch (err) {
460
467
  const retryableError = this.isRetryableError(err);
461
- if (attempt >= this.jobRetryMaxAttempts || !retryableError) {
468
+ const attemptElapsedMs = Math.max(1, Date.now() - attemptStartedAtMs);
469
+ const timeoutMs = resolveDockerJobTimeoutMs(this.options.timeoutMs, job);
470
+ const hasBudgetForRetry =
471
+ retryableError &&
472
+ attempt < this.jobRetryMaxAttempts &&
473
+ this.hasBudgetForJobRetry(attempt, attemptElapsedMs, timeoutMs, onLog);
474
+ if (attempt >= this.jobRetryMaxAttempts || !retryableError || !hasBudgetForRetry) {
462
475
  if (
463
476
  retryableError &&
464
477
  attempt >= this.jobRetryMaxAttempts &&
@@ -1208,17 +1221,28 @@ export class DockerExecutor {
1208
1221
  "bun",
1209
1222
  "run",
1210
1223
  "/workspace/apps/workerpals/src/job_runner.ts",
1211
- base64Spec,
1224
+ "--spec-stdin",
1212
1225
  ];
1213
1226
 
1214
1227
  console.log(
1215
1228
  `[DockerExecutor] Running job in warm container: ${this.warmContainerName} (${this.executionConfigSummary()})`,
1216
1229
  );
1217
1230
 
1218
- const proc = Bun.spawn([resolveDockerExecutable(), ...args], {
1219
- stdout: "pipe",
1220
- stderr: "pipe",
1221
- });
1231
+ const dockerArgv = [resolveDockerExecutable(), ...args];
1232
+ let proc: ReturnType<typeof Bun.spawn>;
1233
+ try {
1234
+ proc = Bun.spawn(dockerArgv, {
1235
+ stdin: "pipe",
1236
+ stdout: "pipe",
1237
+ stderr: "pipe",
1238
+ });
1239
+ } catch (err) {
1240
+ throw new Error(
1241
+ `failed to spawn warm-container docker exec (${this.warmContainerName}, cwd=${containerWorktreePath}, argv_chars=${dockerArgv.join("\u0000").length}, spec_chars=${base64Spec.length}): ${this.compactError(
1242
+ err,
1243
+ )}`,
1244
+ );
1245
+ }
1222
1246
  const timeoutMs = resolveDockerJobTimeoutMs(this.options.timeoutMs, job);
1223
1247
  if (timeoutMs !== this.options.timeoutMs) {
1224
1248
  const verb = timeoutMs > this.options.timeoutMs ? "Extended" : "Capped";
@@ -1263,10 +1287,24 @@ export class DockerExecutor {
1263
1287
  const stdoutLines: string[] = [];
1264
1288
  const stderrLines: string[] = [];
1265
1289
 
1266
- await Promise.all([
1267
- this.readStream(proc.stdout, "stdout", onLog, stdoutLines),
1268
- this.readStream(proc.stderr, "stderr", onLog, stderrLines),
1269
- ]);
1290
+ try {
1291
+ await Promise.all([
1292
+ this.writeJobSpecToStdin(proc, base64Spec),
1293
+ this.readStream(proc.stdout, "stdout", onLog, stdoutLines),
1294
+ this.readStream(proc.stderr, "stderr", onLog, stderrLines),
1295
+ ]);
1296
+ } catch (err) {
1297
+ try {
1298
+ proc.kill();
1299
+ } catch {
1300
+ // Ignore cleanup errors after stream setup failures.
1301
+ }
1302
+ throw new Error(
1303
+ `failed while streaming warm-container job execution (${this.warmContainerName}, spec_chars=${base64Spec.length}): ${this.compactError(
1304
+ err,
1305
+ )}`,
1306
+ );
1307
+ }
1270
1308
 
1271
1309
  clearTimeout(warningTimer);
1272
1310
  clearTimeout(timer);
@@ -1283,6 +1321,28 @@ export class DockerExecutor {
1283
1321
  return result;
1284
1322
  }
1285
1323
 
1324
+ private async writeJobSpecToStdin(
1325
+ proc: ReturnType<typeof Bun.spawn>,
1326
+ base64Spec: string,
1327
+ ): Promise<void> {
1328
+ const stdin = proc.stdin as WritableStream<Uint8Array> | undefined;
1329
+ if (!stdin) {
1330
+ throw new Error("docker exec stdin pipe was not available");
1331
+ }
1332
+ const writer = stdin.getWriter();
1333
+ try {
1334
+ await writer.write(new TextEncoder().encode(base64Spec));
1335
+ await writer.close();
1336
+ } catch (err) {
1337
+ try {
1338
+ await writer.abort(err);
1339
+ } catch {
1340
+ // Ignore abort failures; the original write error is more useful.
1341
+ }
1342
+ throw err;
1343
+ }
1344
+ }
1345
+
1286
1346
  private async ensureWorktreeDependencyArtifacts(
1287
1347
  containerWorktreePath: string,
1288
1348
  onLog?: (stream: "stdout" | "stderr", line: string) => void,
@@ -1785,6 +1845,23 @@ export class DockerExecutor {
1785
1845
  return transientPatterns.some((pattern) => pattern.test(text));
1786
1846
  }
1787
1847
 
1848
+ private hasBudgetForJobRetry(
1849
+ attempt: number,
1850
+ attemptElapsedMs: number,
1851
+ timeoutMs: number,
1852
+ onLog?: (stream: "stdout" | "stderr", line: string) => void,
1853
+ ): boolean {
1854
+ if (attempt >= this.jobRetryMaxAttempts) return false;
1855
+ const consumedRatio = timeoutMs > 0 ? attemptElapsedMs / timeoutMs : 1;
1856
+ if (attemptElapsedMs < Math.max(300_000, timeoutMs * 0.8) && consumedRatio < 0.8) return true;
1857
+ const note = `[DockerExecutor] Skipping retry attempt ${
1858
+ attempt + 1
1859
+ }/${this.jobRetryMaxAttempts}: prior attempt consumed ${attemptElapsedMs}ms of ${timeoutMs}ms budget.`;
1860
+ console.warn(note);
1861
+ onLog?.("stderr", note);
1862
+ return false;
1863
+ }
1864
+
1788
1865
  /**
1789
1866
  * Convert Windows path to Docker-compatible path
1790
1867
  * C:\foo\bar → /c/foo/bar
@@ -1839,10 +1916,9 @@ export class DockerExecutor {
1839
1916
  }
1840
1917
 
1841
1918
  private buildEphemeralWorktreeName(prefix: "job" | "selfcheck", token: string): string {
1842
- const safeWorker = this.sanitizeWorktreeToken(this.options.workerId, 24);
1843
- const safeToken = this.sanitizeWorktreeToken(token, 40);
1844
- const nonce = `${Date.now().toString(36)}-${randomUUID().slice(0, 8).toLowerCase()}`;
1845
- return `${prefix}-${safeToken}-${safeWorker}-${nonce}`;
1919
+ const safeToken = this.sanitizeWorktreeToken(token, prefix === "job" ? 8 : 12);
1920
+ const nonce = `${Date.now().toString(36).slice(-6)}-${randomUUID().slice(0, 6).toLowerCase()}`;
1921
+ return `${prefix}-${safeToken}-${nonce}`;
1846
1922
  }
1847
1923
 
1848
1924
  private sanitizeWorktreeToken(value: string, maxLength: number): string {
@@ -191,6 +191,34 @@ export function qualityRevisionLoopUpperBound(policy: {
191
191
  );
192
192
  }
193
193
 
194
+ export function qualityRevisionBudgetDecision(opts: {
195
+ jobElapsedMs: number;
196
+ executionBudgetMs: number;
197
+ }): {
198
+ shouldStart: boolean;
199
+ remainingBudgetMs: number;
200
+ minimumRevisionBudgetMs: number;
201
+ } {
202
+ const executionBudgetMs = Number(opts.executionBudgetMs);
203
+ if (!Number.isFinite(executionBudgetMs) || executionBudgetMs <= 0) {
204
+ return {
205
+ shouldStart: true,
206
+ remainingBudgetMs: Number.POSITIVE_INFINITY,
207
+ minimumRevisionBudgetMs: 0,
208
+ };
209
+ }
210
+ const elapsedMs = Math.max(0, Number(opts.jobElapsedMs) || 0);
211
+ const remainingBudgetMs = Math.max(0, Math.floor(executionBudgetMs - elapsedMs));
212
+ const minimumRevisionBudgetMs = Math.floor(
213
+ Math.min(executionBudgetMs, Math.max(180_000, Math.min(600_000, executionBudgetMs * 0.35))),
214
+ );
215
+ return {
216
+ shouldStart: remainingBudgetMs >= minimumRevisionBudgetMs,
217
+ remainingBudgetMs,
218
+ minimumRevisionBudgetMs,
219
+ };
220
+ }
221
+
194
222
  function taskRequestsBrowserValidation(params: Record<string, unknown>): boolean {
195
223
  const candidates: string[] = [];
196
224
  const collect = (value: unknown) => {
@@ -384,6 +412,11 @@ function isNonPublishableArtifactPath(path: string): boolean {
384
412
  );
385
413
  }
386
414
 
415
+ function isNestedNodeModulesChange(path: string): boolean {
416
+ const normalized = path.replace(/\\/g, "/").replace(/^\.?\//, "").replace(/\/+$/, "");
417
+ return /(^|\/)node_modules\/.+/i.test(normalized);
418
+ }
419
+
387
420
  export function publishableChangedPaths(changedPaths: string[]): string[] {
388
421
  return changedPaths.filter((path) => !isNonPublishableArtifactPath(path));
389
422
  }
@@ -1578,6 +1611,45 @@ function pathMatchesScopeHint(path: string, hint: string): boolean {
1578
1611
  return normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`);
1579
1612
  }
1580
1613
 
1614
+ function isValidationScopeTestPathHint(path: string): boolean {
1615
+ const normalized = path.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
1616
+ return /(^|\/)(__tests__|tests?)(\/|$)|\.(test|spec)\.[cm]?[jt]sx?$/i.test(normalized);
1617
+ }
1618
+
1619
+ function shouldTreatBrowserAssertionAsTaskScope(
1620
+ planning: TaskExecutePlanning,
1621
+ changedPaths: string[],
1622
+ targetPath?: string,
1623
+ ): boolean {
1624
+ const pathHints = [
1625
+ targetPath ?? "",
1626
+ ...changedPaths,
1627
+ ...(planning.targetPaths ?? []),
1628
+ ...(planning.scope.writeGlobs ?? []),
1629
+ ]
1630
+ .map((entry) => entry.trim().replace(/\\/g, "/"))
1631
+ .filter(Boolean);
1632
+ const allHintsAreTests =
1633
+ pathHints.length > 0 && pathHints.every((hint) => isValidationScopeTestPathHint(hint));
1634
+ const planningText = collectPlanningText(planning);
1635
+ const explicitlyBrowserValidation =
1636
+ /\b(browser|web:e2e|e2e|playwright|smoke)\b/i.test(planningText);
1637
+ if (allHintsAreTests && !explicitlyBrowserValidation) return false;
1638
+
1639
+ const productPathChanged = changedPaths.some((path) => {
1640
+ const normalized = path.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
1641
+ return (
1642
+ !isValidationScopeTestPathHint(normalized) &&
1643
+ /^(app|components|screens|styles|utils)\//i.test(normalized)
1644
+ );
1645
+ });
1646
+ if (productPathChanged) return true;
1647
+
1648
+ return /\b(ui|visual|render(?:ing)?|style|screen|route|home|settings|shop|game|battlefield|component|control panel|control-panel)\b/i.test(
1649
+ planningText,
1650
+ );
1651
+ }
1652
+
1581
1653
  export function classifyValidationFailureScope(
1582
1654
  runs: ValidationExecutionResult[],
1583
1655
  planning: TaskExecutePlanning,
@@ -1600,12 +1672,14 @@ export function classifyValidationFailureScope(
1600
1672
  .flatMap((run) => [run.stdout, run.stderr])
1601
1673
  .filter(Boolean)
1602
1674
  .join("\n");
1675
+ const hasBrowserAssertionFailure = failedRuns.some(
1676
+ (run) =>
1677
+ isLongRunningBrowserValidationCommand(run.command) &&
1678
+ isBrowserAssertionDigest([run.stdout, run.stderr].filter(Boolean).join("\n")),
1679
+ );
1603
1680
  if (
1604
- failedRuns.some(
1605
- (run) =>
1606
- isLongRunningBrowserValidationCommand(run.command) &&
1607
- isBrowserAssertionDigest([run.stdout, run.stderr].filter(Boolean).join("\n")),
1608
- )
1681
+ hasBrowserAssertionFailure &&
1682
+ shouldTreatBrowserAssertionAsTaskScope(planning, changedPaths, targetPath)
1609
1683
  ) {
1610
1684
  return "task_scope";
1611
1685
  }
@@ -1620,7 +1694,9 @@ export function classifyValidationFailureScope(
1620
1694
  const pathTokens = extractPathTokensFromValidationOutput(combined).filter(
1621
1695
  (token) => !/^(node_modules|\.bun|bun|npm|pnpm|yarn)\//i.test(token),
1622
1696
  );
1623
- if (pathTokens.length === 0) return "none";
1697
+ if (pathTokens.length === 0) {
1698
+ return hasBrowserAssertionFailure ? "outside_task_scope" : "none";
1699
+ }
1624
1700
  if (pathTokens.some((token) => scopeHints.some((hint) => pathMatchesScopeHint(token, hint)))) {
1625
1701
  return "task_scope";
1626
1702
  }
@@ -2901,7 +2977,7 @@ export function collectPrePublishHygieneIssues(params: {
2901
2977
  }
2902
2978
  }
2903
2979
 
2904
- if (changedPaths.some((path) => /(^|\/)node_modules(\/|$)/i.test(path))) {
2980
+ if (changedPaths.some((path) => isNestedNodeModulesChange(path))) {
2905
2981
  issues.push("attempted to publish node_modules changes; dependency installs must not become PR content.");
2906
2982
  }
2907
2983
 
@@ -6741,6 +6817,7 @@ export async function executeJob(
6741
6817
 
6742
6818
  let revisionAttempt = 0;
6743
6819
  let revisionHint = "";
6820
+ const jobStartedAt = Date.now();
6744
6821
  const previousValidationFailureDigests = new Map<string, string>();
6745
6822
  const failureJobFamily = buildTaskFailureJobFamily(normalizedParams);
6746
6823
  while (revisionAttempt <= qualityRevisionLoopMax) {
@@ -6854,7 +6931,7 @@ export async function executeJob(
6854
6931
  );
6855
6932
  return {
6856
6933
  ok: false,
6857
- summary: "Executor produced no publishable code changes",
6934
+ summary: `Executor produced no publishable code changes (${detail})`,
6858
6935
  stdout: result.stdout,
6859
6936
  stderr: [result.stderr ?? "", detail].filter(Boolean).join("\n"),
6860
6937
  exitCode: 4,
@@ -7180,6 +7257,38 @@ export async function executeJob(
7180
7257
  };
7181
7258
  }
7182
7259
 
7260
+ const revisionBudget = qualityRevisionBudgetDecision({
7261
+ jobElapsedMs: Date.now() - jobStartedAt,
7262
+ executionBudgetMs,
7263
+ });
7264
+ if (!revisionBudget.shouldStart) {
7265
+ const budgetSummary = `Quality gate needs revision ${
7266
+ revisionAttempt + 1
7267
+ }/${activeMaxAutoRevisions}, but remaining execution budget is ${
7268
+ revisionBudget.remainingBudgetMs
7269
+ }ms (< ${revisionBudget.minimumRevisionBudgetMs}ms); stopping before another worker turn to preserve a structured result: ${toSingleLine(
7270
+ issueSummary,
7271
+ 220,
7272
+ )}`;
7273
+ onLog?.("stderr", `[QualityGate] ${budgetSummary}`);
7274
+ return {
7275
+ ok: false,
7276
+ summary: budgetSummary,
7277
+ stdout: result.stdout,
7278
+ stderr: truncate(
7279
+ [
7280
+ result.stderr ?? "",
7281
+ ...quality.validationRuns.flatMap((run) => [run.stdout, run.stderr]).filter(Boolean),
7282
+ critic ? `Critic raw: ${critic.raw}` : "",
7283
+ ]
7284
+ .filter(Boolean)
7285
+ .join("\n"),
7286
+ outputPolicyForRuntime(runtimeConfig),
7287
+ ),
7288
+ exitCode: 4,
7289
+ };
7290
+ }
7291
+
7183
7292
  revisionAttempt += 1;
7184
7293
  revisionHint = buildQualityRevisionHint(
7185
7294
  issues,
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * Usage (inside container):
9
9
  * bun run job_runner.ts <base64-encoded-job-spec>
10
+ * bun run job_runner.ts --spec-stdin
10
11
  *
11
12
  * The job spec is base64-encoded JSON: { jobId, taskId, kind, params, workerId }
12
13
  *
@@ -116,11 +117,19 @@ echo "password=${token}"
116
117
 
117
118
  async function main(): Promise<void> {
118
119
  const args = process.argv.slice(2);
119
- const base64Spec = args[0];
120
+ const rawSpecArg = args[0];
120
121
 
122
+ if (!rawSpecArg) {
123
+ // eslint-disable-next-line no-console
124
+ console.error("Usage: bun run job_runner.ts <base64-encoded-job-spec>|--spec-stdin");
125
+ process.exit(1);
126
+ }
127
+
128
+ const base64Spec =
129
+ rawSpecArg === "--spec-stdin" ? (await Bun.stdin.text()).trim() : rawSpecArg;
121
130
  if (!base64Spec) {
122
131
  // eslint-disable-next-line no-console
123
- console.error("Usage: bun run job_runner.ts <base64-encoded-job-spec>");
132
+ console.error("Job spec was empty");
124
133
  process.exit(1);
125
134
  }
126
135
 
@@ -100,6 +100,14 @@ function estimateTokensFromText(text: string): number {
100
100
  return Math.max(0, Math.ceil(String(text ?? "").length / 3));
101
101
  }
102
102
 
103
+ function compactWorkerError(error: unknown, maxLength = 220): string {
104
+ const raw = error instanceof Error ? error.message : String(error ?? "");
105
+ const normalized = raw.replace(/\s+/g, " ").trim();
106
+ if (!normalized) return "unknown error";
107
+ if (normalized.length <= maxLength) return normalized;
108
+ return `${normalized.slice(0, maxLength - 3)}...`;
109
+ }
110
+
103
111
  async function postJsonWithTimeout(
104
112
  url: string,
105
113
  headers: Record<string, string>,
@@ -693,10 +701,17 @@ async function createIsolatedWorktree(
693
701
  ): Promise<string> {
694
702
  const worktreeRoot = resolve(repo, ".worktrees");
695
703
  mkdirSync(worktreeRoot, { recursive: true });
704
+ const safeJobId = jobId
705
+ .toLowerCase()
706
+ .replace(/[^a-z0-9]+/g, "")
707
+ .slice(0, 8);
708
+ const nonce = `${Date.now().toString(36).slice(-6)}-${Math.random()
709
+ .toString(36)
710
+ .slice(2, 6)}`;
696
711
 
697
712
  const worktreePath = resolve(
698
713
  worktreeRoot,
699
- `host-job-${jobId}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
714
+ `job-${safeJobId || "host"}-${nonce}`,
700
715
  );
701
716
 
702
717
  const addResult = await git(repo, ["worktree", "add", "--detach", worktreePath, baseRef]);
@@ -1532,9 +1547,10 @@ async function workerLoop(
1532
1547
  Number.isFinite(err.cooldownMs) ? err.cooldownMs : 0,
1533
1548
  );
1534
1549
  }
1550
+ const errorSummary = compactWorkerError(err);
1535
1551
  result = {
1536
1552
  ok: false,
1537
- summary: "Job execution failed before completion",
1553
+ summary: `Job execution failed before completion: ${errorSummary}`,
1538
1554
  stderr: String(err),
1539
1555
  ...(cooldownAfterJobMs > 0 ? { cooldownMs: cooldownAfterJobMs } : {}),
1540
1556
  };