@pushpalsdev/cli 1.1.9 → 1.1.11

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.
@@ -67,6 +67,7 @@ const DEFAULT_LLM_MODEL = "local-model";
67
67
  const CODEX_UNAVAILABLE_WORKER_EXIT_CODE = 86;
68
68
  const CODEX_UNAVAILABLE_DOCKER_SHUTDOWN_GRACE_MS = 5_000;
69
69
  const CODEX_UNAVAILABLE_WORKER_FORCE_EXIT_MS = 4_000;
70
+ const DEFAULT_JOB_PROGRESS_LOG_EVERY_MS = 60_000;
70
71
  const CONFIG = loadPushPalsConfig();
71
72
  const LOG = new Logger("WorkerPals");
72
73
 
@@ -197,7 +198,12 @@ async function reportToolRunForUnsuccessfulJob(args: {
197
198
  if (record.failureClass === "unknown" && record.tool === "shell") return;
198
199
 
199
200
  try {
200
- const response = await postJsonWithTimeout(`${args.opts.server}/tool-runs`, args.headers, record, 5_000);
201
+ const response = await postJsonWithTimeout(
202
+ `${args.opts.server}/tool-runs`,
203
+ args.headers,
204
+ record,
205
+ 5_000,
206
+ );
201
207
  if (!response.ok) {
202
208
  const detail = await response.text().catch(() => "");
203
209
  console.warn(
@@ -315,6 +321,13 @@ function formatDurationMs(durationMs: number): string {
315
321
  return `${minutes}m ${seconds}s`;
316
322
  }
317
323
 
324
+ function resolveJobProgressLogEveryMs(): number {
325
+ const raw = Number.parseInt(process.env.PUSHPALS_WORKERPAL_PROGRESS_LOG_MS ?? "", 10);
326
+ if (Number.isFinite(raw) && raw === 0) return 0;
327
+ if (Number.isFinite(raw) && raw >= 10_000) return raw;
328
+ return DEFAULT_JOB_PROGRESS_LOG_EVERY_MS;
329
+ }
330
+
318
331
  function sanitizeJobLogLine(line: string): string {
319
332
  // Strip ANSI escape/control sequences and collapse whitespace.
320
333
  const cleaned = line
@@ -329,6 +342,70 @@ function isNoisyProgressLine(line: string): boolean {
329
342
  return /^(📦 Installing \[\d+\/\d+\]|🔍 Resolving\.\.\.|🔒 Saving lockfile\.\.\.)$/.test(line);
330
343
  }
331
344
 
345
+ type WorkerJobPhase =
346
+ | "discovering"
347
+ | "editing"
348
+ | "test harness repair"
349
+ | "focused validation"
350
+ | "full validation"
351
+ | "final diff review"
352
+ | "publishing"
353
+ | "quality revision";
354
+
355
+ function inferWorkerJobPhaseFromLogLine(line: string): WorkerJobPhase | null {
356
+ const text = String(line ?? "").trim();
357
+ if (!text) return null;
358
+ if (/Quality gate requested revision|Quality revision required|revision guidance/i.test(text)) {
359
+ return "quality revision";
360
+ }
361
+ if (
362
+ /test harness|React Native package|reactNativeMock|mock helper|mock was missing|expo-secure-store|import error|Cannot find module|does not provide an export|no exported member|Animated\.View|SettingsContext|skin validator/i.test(
363
+ text,
364
+ )
365
+ ) {
366
+ return "test harness repair";
367
+ }
368
+ if (
369
+ /focused validation|focused checks|targeted test|focused test|new regression|focused regression|fast checks|rerunning .*regression|node --check/i.test(
370
+ text,
371
+ )
372
+ ) {
373
+ return "focused validation";
374
+ }
375
+ if (
376
+ /ValidationGate|required validation|full .*test suite|whole Bun test|repo-level|bun test\b|bunx? tsc|typecheck|type check|bun run lint|web:e2e|browser smoke/i.test(
377
+ text,
378
+ )
379
+ ) {
380
+ return "full validation";
381
+ }
382
+ if (/creating commit|Publish blocked|publish-blocked|completion ref|enqueueCompletion/i.test(text)) {
383
+ return "publishing";
384
+ }
385
+ if (
386
+ /final diff|diff review|git diff|git status|whitespace|line-ending|line ending|pruning|remove unrelated|remaining diff|changed files/i.test(
387
+ text,
388
+ )
389
+ ) {
390
+ return "final diff review";
391
+ }
392
+ if (
393
+ /editing|patch|implemented|adding|fixing|updating|wiring|in place|changes are in place|making .*change|tightening|restore|normalizing/i.test(
394
+ text,
395
+ )
396
+ ) {
397
+ return "editing";
398
+ }
399
+ if (
400
+ /read|inspect|checking|locating|opening|artifact|screenshot|README|context|discover|search|rg |current checkout|worktree/i.test(
401
+ text,
402
+ )
403
+ ) {
404
+ return "discovering";
405
+ }
406
+ return null;
407
+ }
408
+
332
409
  export function shouldEmitDirectSessionJobEvent(options: {
333
410
  ok: boolean;
334
411
  statusPersistedToServer: boolean;
@@ -985,8 +1062,7 @@ function failNoChangeReviewFixJob(jobId: string, result: WorkerJobResult): Worke
985
1062
  return {
986
1063
  ...result,
987
1064
  ok: false,
988
- summary:
989
- `Rejected review-fix job ${jobId} produced no code changes; refusing unchanged branch re-review.`,
1065
+ summary: `Rejected review-fix job ${jobId} produced no code changes; refusing unchanged branch re-review.`,
990
1066
  stderr: [
991
1067
  result.stderr,
992
1068
  "Review-fix jobs must make at least one concrete code/test/docs change before requesting another review.",
@@ -1002,9 +1078,7 @@ function taskExecuteOrigin(params: Record<string, unknown> | undefined): "user"
1002
1078
  if (!params) return "user";
1003
1079
  if (params.origin === "autonomy") return "autonomy";
1004
1080
  const autonomy = params.autonomy;
1005
- return autonomy && typeof autonomy === "object" && !Array.isArray(autonomy)
1006
- ? "autonomy"
1007
- : "user";
1081
+ return autonomy && typeof autonomy === "object" && !Array.isArray(autonomy) ? "autonomy" : "user";
1008
1082
  }
1009
1083
 
1010
1084
  async function enqueueCompletion(
@@ -1109,15 +1183,19 @@ async function failActiveJobOnShutdown(
1109
1183
  runtimeState.currentSessionId &&
1110
1184
  shouldEmitDirectSessionJobEvent({ ok: false, statusPersistedToServer })
1111
1185
  ) {
1112
- await transport.queueSessionCommand(runtimeState.currentSessionId, {
1113
- type: "job_failed",
1114
- payload: {
1115
- jobId: activeJobId,
1116
- message,
1117
- detail,
1186
+ await transport.queueSessionCommand(
1187
+ runtimeState.currentSessionId,
1188
+ {
1189
+ type: "job_failed",
1190
+ payload: {
1191
+ jobId: activeJobId,
1192
+ message,
1193
+ detail,
1194
+ },
1195
+ from: `worker:${opts.workerId}`,
1118
1196
  },
1119
- from: `worker:${opts.workerId}`,
1120
- }, { priority: "high" });
1197
+ { priority: "high" },
1198
+ );
1121
1199
  }
1122
1200
  }
1123
1201
 
@@ -1224,10 +1302,7 @@ async function workerLoop(
1224
1302
  const job = data.job;
1225
1303
 
1226
1304
  if (job) {
1227
- if (
1228
- dockerExecutor &&
1229
- dockerExecutor.shouldPrepareMergeConflictJobBeforeExecution(job)
1230
- ) {
1305
+ if (dockerExecutor && dockerExecutor.shouldPrepareMergeConflictJobBeforeExecution(job)) {
1231
1306
  const deferMs = dockerExecutor.recommendedMergeConflictDeferMs();
1232
1307
  const deferred = await deferClaimedJobForMaintenance(opts, headers, job.id, deferMs);
1233
1308
  if (!deferred.ok) {
@@ -1325,50 +1400,95 @@ async function workerLoop(
1325
1400
  }, heartbeatEveryMs);
1326
1401
 
1327
1402
  if (job.sessionId) {
1328
- await transport.queueSessionCommand(job.sessionId, {
1329
- type: "job_claimed",
1330
- payload: { jobId: job.id, workerId: opts.workerId },
1331
- from: `worker:${opts.workerId}`,
1332
- }, { priority: "high" });
1403
+ await transport.queueSessionCommand(
1404
+ job.sessionId,
1405
+ {
1406
+ type: "job_claimed",
1407
+ payload: { jobId: job.id, workerId: opts.workerId },
1408
+ from: `worker:${opts.workerId}`,
1409
+ },
1410
+ { priority: "high" },
1411
+ );
1333
1412
  }
1334
1413
 
1335
1414
  let stdoutSeq = 0;
1336
1415
  let stderrSeq = 0;
1337
1416
  let lastCleanLog = "";
1338
1417
  let lastCleanLogAt = 0;
1418
+ let lastForwardedJobLogAt = Date.now();
1419
+ let currentJobPhase: WorkerJobPhase | null = null;
1339
1420
 
1340
- const onLog = job.sessionId
1341
- ? (stream: "stdout" | "stderr", line: string) => {
1421
+ const emitJobLog = job.sessionId
1422
+ ? (stream: "stdout" | "stderr", line: string): boolean => {
1342
1423
  const cleaned = sanitizeJobLogLine(line);
1343
- if (!cleaned) return;
1344
- // Print executor logs locally only in debug mode.
1345
- if (LOG.isDebugEnabled()) LOG.debug(`[${stream}] ${cleaned}`);
1424
+ if (!cleaned) return false;
1346
1425
 
1347
1426
  // Drop high-frequency terminal progress redraw spam; keep meaningful lines.
1348
- if (isNoisyProgressLine(cleaned)) return;
1427
+ if (isNoisyProgressLine(cleaned)) return false;
1349
1428
 
1350
1429
  // Collapse very noisy duplicate lines emitted in tight loops.
1351
1430
  const now = Date.now();
1352
- if (cleaned === lastCleanLog && now - lastCleanLogAt < 1_000) return;
1431
+ if (cleaned === lastCleanLog && now - lastCleanLogAt < 1_000) return false;
1353
1432
  lastCleanLog = cleaned;
1354
1433
  lastCleanLogAt = now;
1434
+ lastForwardedJobLogAt = now;
1435
+ currentJobPhase = inferWorkerJobPhaseFromLogLine(cleaned) ?? currentJobPhase;
1355
1436
  const logTs = new Date(now).toISOString();
1356
1437
 
1357
1438
  const seq = stream === "stdout" ? ++stdoutSeq : ++stderrSeq;
1358
- void transport.queueSessionCommand(job.sessionId, {
1359
- type: "job_log",
1360
- payload: { jobId: job.id, stream, seq, line: cleaned, ts: logTs },
1361
- from: `worker:${opts.workerId}`,
1362
- }, { droppable: true });
1439
+ void transport.queueSessionCommand(
1440
+ job.sessionId,
1441
+ {
1442
+ type: "job_log",
1443
+ payload: {
1444
+ jobId: job.id,
1445
+ stream,
1446
+ seq,
1447
+ line: cleaned,
1448
+ ts: logTs,
1449
+ phase: currentJobPhase,
1450
+ },
1451
+ from: `worker:${opts.workerId}`,
1452
+ },
1453
+ { droppable: true },
1454
+ );
1363
1455
  void transport.queueJobLog(job.id, {
1364
1456
  stream,
1365
1457
  seq,
1366
1458
  message: cleaned,
1367
1459
  ts: logTs,
1368
1460
  });
1461
+ return true;
1369
1462
  }
1370
1463
  : undefined;
1371
1464
 
1465
+ const onLog = emitJobLog
1466
+ ? (stream: "stdout" | "stderr", line: string) => {
1467
+ const cleaned = sanitizeJobLogLine(line);
1468
+ if (LOG.isDebugEnabled() && cleaned) LOG.debug(`[${stream}] ${cleaned}`);
1469
+ emitJobLog(stream, line);
1470
+ }
1471
+ : undefined;
1472
+
1473
+ const jobClaimedAtMs = Date.now();
1474
+ const jobProgressLogEveryMs = resolveJobProgressLogEveryMs();
1475
+ const jobProgressTimer =
1476
+ emitJobLog && jobProgressLogEveryMs > 0
1477
+ ? setInterval(() => {
1478
+ const now = Date.now();
1479
+ const quietForMs = Math.max(0, now - lastForwardedJobLogAt);
1480
+ if (quietForMs < jobProgressLogEveryMs) return;
1481
+ emitJobLog(
1482
+ "stdout",
1483
+ `[WorkerPals] Job ${job.id} still running after ${formatDurationMs(
1484
+ now - jobClaimedAtMs,
1485
+ )} (kind=${job.kind}, worker=${opts.workerId}, phase=${
1486
+ currentJobPhase ?? "unknown"
1487
+ }, quiet_for=${formatDurationMs(quietForMs)}).`,
1488
+ );
1489
+ }, jobProgressLogEveryMs)
1490
+ : null;
1491
+
1372
1492
  let directWorktreePath: string | null = null;
1373
1493
  let executionRepo = opts.repo;
1374
1494
  let result: WorkerJobResult | null = null;
@@ -1611,11 +1731,15 @@ async function workerLoop(
1611
1731
  durationMs: jobDurationMs,
1612
1732
  phase: job.kind,
1613
1733
  });
1614
- const response = await postJsonWithTimeout(`${opts.server}/jobs/${job.id}/fail`, headers, {
1615
- message: result.summary,
1616
- detail: redactSensitiveText(result.stderr ?? ""),
1617
- durationMs: jobDurationMs,
1618
- });
1734
+ const response = await postJsonWithTimeout(
1735
+ `${opts.server}/jobs/${job.id}/fail`,
1736
+ headers,
1737
+ {
1738
+ message: result.summary,
1739
+ detail: redactSensitiveText(result.stderr ?? ""),
1740
+ durationMs: jobDurationMs,
1741
+ },
1742
+ );
1619
1743
  statusPersistedToServer = response.ok;
1620
1744
  console.log(
1621
1745
  `[WorkerPals] Job ${job.id} failed in ${formatDurationMs(jobDurationMs)}: ${result.summary}`,
@@ -1703,6 +1827,7 @@ async function workerLoop(
1703
1827
  }
1704
1828
  } finally {
1705
1829
  clearInterval(busyHeartbeat);
1830
+ if (jobProgressTimer) clearInterval(jobProgressTimer);
1706
1831
  if (recycleWorkerAfterJob) {
1707
1832
  runtimeState.shutdownRequested = true;
1708
1833
  const forceExitTimer = setTimeout(() => {
@@ -1895,7 +2020,9 @@ async function main(): Promise<void> {
1895
2020
  },
1896
2021
  }),
1897
2022
  );
1898
- await withTimeout(failActiveJobOnShutdown(opts, headers, runtimeState, transport, signalName));
2023
+ await withTimeout(
2024
+ failActiveJobOnShutdown(opts, headers, runtimeState, transport, signalName),
2025
+ );
1899
2026
  await withTimeout(transport.flush());
1900
2027
  if (dockerExecutor) {
1901
2028
  await withTimeout(