@pushpalsdev/cli 1.1.15 → 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;
@@ -3450,7 +3451,7 @@ function isWorkerpalEphemeralWorktreePath(repoRoot, worktreePath) {
3450
3451
  if (!normalizedPath.startsWith(expectedPrefix))
3451
3452
  return false;
3452
3453
  const leaf = basename(normalizedPath);
3453
- return /^(job|selfcheck)-.*-workerpal-[a-z0-9._-]+/i.test(leaf);
3454
+ return /^(job|selfcheck)-[a-z0-9][a-z0-9._-]*$/i.test(leaf);
3454
3455
  }
3455
3456
  function resolveConfiguredDockerExecutable(env, platform = process.platform) {
3456
3457
  const configured = String(env.PUSHPALS_DOCKER_BIN_ABSOLUTE ?? env.PUSHPALS_DOCKER_BIN ?? (platform === "win32" ? "docker.exe" : "docker")).trim();
@@ -4248,6 +4249,16 @@ function extractRemoteBuddySessionConsumerHealth(statusPayload, sessionId) {
4248
4249
  detail: `No connected RemoteBuddy session consumer found for session ${sessionId}`
4249
4250
  };
4250
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
+ }
4251
4262
  async function probeRemoteBuddySessionConsumer(serverUrl, sessionId) {
4252
4263
  try {
4253
4264
  const response = await fetchWithTimeout(`${serverUrl}/system/status`, {}, 1e4);
@@ -4716,6 +4727,7 @@ ${tail}` : ""}`);
4716
4727
  const optionalServiceExitWarned = new Set;
4717
4728
  let lastReadinessWaitLogAt = 0;
4718
4729
  let lastReadinessWaitDetail = "";
4730
+ let deferredRemoteBuddyConsumerLogged = false;
4719
4731
  while (Date.now() < deadline) {
4720
4732
  reportRemoteBuddyAutonomousEngineState();
4721
4733
  if (maybeActivateRemoteBuddyWindowsFallback("silent_startup")) {
@@ -4759,7 +4771,15 @@ ${tail}` : ""}`);
4759
4771
  }
4760
4772
  const health = localBuddyEnabled ? await probeLocalBuddy(opts.localAgentUrl) : null;
4761
4773
  const remoteBuddyHealth2 = await probeRemoteBuddySessionConsumer(opts.serverUrl, opts.sessionId);
4762
- 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) {
4763
4783
  const localBuddyDetail = localBuddyEnabled ? health?.ok ? "LocalBuddy ready" : "LocalBuddy not ready" : "LocalBuddy skipped";
4764
4784
  const readinessDetail = `${localBuddyDetail}; ${remoteBuddyHealth2.detail}`;
4765
4785
  const now = Date.now();
@@ -4770,7 +4790,11 @@ ${tail}` : ""}`);
4770
4790
  lastReadinessWaitLogAt = now;
4771
4791
  }
4772
4792
  }
4773
- 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
+ }
4774
4798
  reportRemoteBuddyAutonomousEngineState();
4775
4799
  const stabilityDeadline = Date.now() + DEFAULT_SERVICE_STABILITY_GRACE_MS;
4776
4800
  while (Date.now() < stabilityDeadline) {
@@ -6040,6 +6064,7 @@ export {
6040
6064
  shouldShowCliSessionOperationalEvents,
6041
6065
  shouldRunEmbeddedRuntimeStartupPrechecks,
6042
6066
  shouldRestartEmbeddedService,
6067
+ shouldDeferRemoteBuddySessionConsumerReadiness,
6043
6068
  runCommandWithEnv,
6044
6069
  resolveWorkerpalDockerProbe,
6045
6070
  resolveWorkerExecutionReadiness,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.1.15",
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
  };
@@ -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
  };