@pushpalsdev/cli 1.0.33 → 1.0.34

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.0.33",
3
+ "version": "1.0.34",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -236,6 +236,7 @@ export class DockerExecutor {
236
236
  private readonly jobRetryMaxAttempts: number;
237
237
  private readonly jobRetryBackoffMs: number;
238
238
  private readonly failureCooldownMs: number;
239
+ private readonly worktreeVisibilityTimeoutMs: number;
239
240
  private lastLoggedExecutionConfig = "";
240
241
  private lastLoggedEndpointRewrite = "";
241
242
  private warmedBackends = new Set<string>();
@@ -294,6 +295,7 @@ export class DockerExecutor {
294
295
  20_000,
295
296
  300_000,
296
297
  );
298
+ this.worktreeVisibilityTimeoutMs = process.platform === "win32" ? 15_000 : 5_000;
297
299
 
298
300
  // Ensure worktrees directory exists
299
301
  try {
@@ -416,7 +418,10 @@ export class DockerExecutor {
416
418
  try {
417
419
  await this.createWorktree(worktreePath, this.options.baseRef);
418
420
  await this.runGitSelfCheckContainer(worktreePath);
419
- console.log(`[DockerExecutor] Startup self-check passed (git/worktree in container).`);
421
+ await this.ensureWorktreeAccessibleInWarmContainer(worktreePath);
422
+ console.log(
423
+ `[DockerExecutor] Startup self-check passed (git/worktree in container and warm container).`,
424
+ );
420
425
  } finally {
421
426
  await this.removeWorktree(worktreePath).catch(() => {
422
427
  // Ignore cleanup failures for startup self-check artifacts.
@@ -837,6 +842,41 @@ export class DockerExecutor {
837
842
  };
838
843
  }
839
844
 
845
+ private async runWarmWorktreeProbe(containerWorktreePath: string): Promise<{
846
+ ok: boolean;
847
+ stdout: string;
848
+ stderr: string;
849
+ exitCode: number;
850
+ }> {
851
+ const proc = Bun.spawn(
852
+ [
853
+ resolveDockerExecutable(),
854
+ "exec",
855
+ "-w",
856
+ containerWorktreePath,
857
+ this.warmContainerName,
858
+ "/bin/sh",
859
+ "-lc",
860
+ "git rev-parse --is-inside-work-tree && git rev-parse --git-dir",
861
+ ],
862
+ {
863
+ stdout: "pipe",
864
+ stderr: "pipe",
865
+ },
866
+ );
867
+ const [stdout, stderr, exitCode] = await Promise.all([
868
+ new Response(proc.stdout).text(),
869
+ new Response(proc.stderr).text(),
870
+ proc.exited,
871
+ ]);
872
+ return {
873
+ ok: exitCode === 0,
874
+ stdout: stdout.trim(),
875
+ stderr: stderr.trim(),
876
+ exitCode,
877
+ };
878
+ }
879
+
840
880
  private async inspectWarmContainerState(): Promise<string> {
841
881
  const proc = Bun.spawn(
842
882
  [
@@ -1054,10 +1094,10 @@ export class DockerExecutor {
1054
1094
  ): Promise<DockerJobResult> {
1055
1095
  await this.ensureWarmRuntimeReady(job, onLog);
1056
1096
  const startedAtMs = Date.now();
1057
-
1058
- const worktreeRelPath = relative(this.options.repo, worktreePath).replace(/\\/g, "/");
1059
- const containerWorktreePath = `/repo/${worktreeRelPath}`;
1060
- await this.waitForWorktreePathInWarmContainer(containerWorktreePath);
1097
+ const containerWorktreePath = await this.ensureWorktreeAccessibleInWarmContainer(
1098
+ worktreePath,
1099
+ onLog,
1100
+ );
1061
1101
 
1062
1102
  const args: string[] = [
1063
1103
  "exec",
@@ -1154,6 +1194,51 @@ export class DockerExecutor {
1154
1194
  );
1155
1195
  }
1156
1196
 
1197
+ private async ensureWorktreeAccessibleInWarmContainer(
1198
+ worktreePath: string,
1199
+ onLog?: (stream: "stdout" | "stderr", line: string) => void,
1200
+ ): Promise<string> {
1201
+ const worktreeRelPath = relative(this.options.repo, worktreePath).replace(/\\/g, "/");
1202
+ const containerWorktreePath = `/repo/${worktreeRelPath}`;
1203
+ let lastError: unknown = null;
1204
+
1205
+ for (let attempt = 1; attempt <= 2; attempt++) {
1206
+ try {
1207
+ await this.ensureWarmContainer();
1208
+ await this.waitForWorktreePathInWarmContainer(
1209
+ containerWorktreePath,
1210
+ this.worktreeVisibilityTimeoutMs,
1211
+ );
1212
+ const probe = await this.runWarmWorktreeProbe(containerWorktreePath);
1213
+ if (probe.ok) {
1214
+ return containerWorktreePath;
1215
+ }
1216
+ const detail = [probe.stderr, probe.stdout].filter(Boolean).join("\n").trim();
1217
+ throw new Error(
1218
+ `warm container git probe failed (exit ${probe.exitCode})${detail ? `: ${detail}` : ""}`,
1219
+ );
1220
+ } catch (err) {
1221
+ lastError = err;
1222
+ if (attempt >= 2) {
1223
+ const diagnostics = await this.inspectWarmContainerState().catch(() => "");
1224
+ throw new Error(
1225
+ `worktree not accessible inside warm container after ${attempt} attempts: ${containerWorktreePath}${
1226
+ lastError ? ` (${this.compactError(lastError)})` : ""
1227
+ }${diagnostics ? ` | container=${diagnostics}` : ""}`,
1228
+ );
1229
+ }
1230
+ const note =
1231
+ `[DockerExecutor] Warm container could not access worktree ${containerWorktreePath}; ` +
1232
+ `recycling container and retrying once (${this.compactError(err)}).`;
1233
+ console.warn(note);
1234
+ onLog?.("stderr", note);
1235
+ await this.stopWarmContainer("worktree visibility retry", true);
1236
+ }
1237
+ }
1238
+
1239
+ return containerWorktreePath;
1240
+ }
1241
+
1157
1242
  private normalizeProvider(raw: string): string {
1158
1243
  const value = raw.trim().toLowerCase();
1159
1244
  if (!value) return "auto";
@@ -215,6 +215,14 @@ function isNoisyProgressLine(line: string): boolean {
215
215
  return /^(📦 Installing \[\d+\/\d+\]|🔍 Resolving\.\.\.|🔒 Saving lockfile\.\.\.)$/.test(line);
216
216
  }
217
217
 
218
+ export function shouldEmitDirectSessionJobEvent(options: {
219
+ ok: boolean;
220
+ statusPersistedToServer: boolean;
221
+ }): boolean {
222
+ if (options.ok) return true;
223
+ return !options.statusPersistedToServer;
224
+ }
225
+
218
226
  function shouldRecycleWorkerForCodexUnavailableFailure(
219
227
  summary: string,
220
228
  stderr?: string | null,
@@ -950,13 +958,15 @@ async function failActiveJobOnShutdown(
950
958
 
951
959
  const message = "Worker process shutting down during claimed job";
952
960
  const detail = `worker=${opts.workerId}; signal=${signalName}; action=fail-claimed-job-on-shutdown`;
961
+ let statusPersistedToServer = false;
953
962
 
954
963
  try {
955
- await fetch(`${opts.server}/jobs/${activeJobId}/fail`, {
964
+ const response = await fetch(`${opts.server}/jobs/${activeJobId}/fail`, {
956
965
  method: "POST",
957
966
  headers,
958
967
  body: JSON.stringify({ message, detail }),
959
968
  });
969
+ statusPersistedToServer = response.ok;
960
970
  } catch (err) {
961
971
  console.error(
962
972
  `[WorkerPals] Failed to mark active job ${activeJobId} as failed during shutdown:`,
@@ -964,7 +974,10 @@ async function failActiveJobOnShutdown(
964
974
  );
965
975
  }
966
976
 
967
- if (runtimeState.currentSessionId) {
977
+ if (
978
+ runtimeState.currentSessionId &&
979
+ shouldEmitDirectSessionJobEvent({ ok: false, statusPersistedToServer })
980
+ ) {
968
981
  await sendCommand(opts.server, runtimeState.currentSessionId, headers, {
969
982
  type: "job_failed",
970
983
  payload: {
@@ -1242,6 +1255,7 @@ async function workerLoop(
1242
1255
  }
1243
1256
  }
1244
1257
 
1258
+ let statusPersistedToServer = false;
1245
1259
  if (result.ok) {
1246
1260
  const reviewAgent =
1247
1261
  parsedParams.reviewAgent && typeof parsedParams.reviewAgent === "object"
@@ -1253,7 +1267,7 @@ async function workerLoop(
1253
1267
  reviewAgent.prUrl.trim().length > 0
1254
1268
  ? reviewAgent.prUrl.trim()
1255
1269
  : null;
1256
- await fetch(`${opts.server}/jobs/${job.id}/complete`, {
1270
+ const response = await fetch(`${opts.server}/jobs/${job.id}/complete`, {
1257
1271
  method: "POST",
1258
1272
  headers,
1259
1273
  body: JSON.stringify({
@@ -1266,11 +1280,12 @@ async function workerLoop(
1266
1280
  ],
1267
1281
  }),
1268
1282
  });
1283
+ statusPersistedToServer = response.ok;
1269
1284
  console.log(
1270
1285
  `[WorkerPals] Job ${job.id} completed in ${formatDurationMs(jobDurationMs)}: ${result.summary}`,
1271
1286
  );
1272
1287
  } else {
1273
- await fetch(`${opts.server}/jobs/${job.id}/fail`, {
1288
+ const response = await fetch(`${opts.server}/jobs/${job.id}/fail`, {
1274
1289
  method: "POST",
1275
1290
  headers,
1276
1291
  body: JSON.stringify({
@@ -1279,6 +1294,7 @@ async function workerLoop(
1279
1294
  durationMs: jobDurationMs,
1280
1295
  }),
1281
1296
  });
1297
+ statusPersistedToServer = response.ok;
1282
1298
  console.log(
1283
1299
  `[WorkerPals] Job ${job.id} failed in ${formatDurationMs(jobDurationMs)}: ${result.summary}`,
1284
1300
  );
@@ -1319,29 +1335,31 @@ async function workerLoop(
1319
1335
  }
1320
1336
  }
1321
1337
 
1322
- const eventCmd = result.ok
1323
- ? {
1324
- type: "job_completed" as const,
1325
- payload: {
1326
- jobId: job.id,
1327
- summary: result.summary,
1328
- artifacts: result.stdout
1329
- ? [{ kind: "log" as const, text: result.stdout }]
1330
- : undefined,
1331
- },
1332
- from: `worker:${opts.workerId}`,
1333
- }
1334
- : {
1335
- type: "job_failed" as const,
1336
- payload: {
1337
- jobId: job.id,
1338
- message: result.summary,
1339
- detail: redactSensitiveText(result.stderr ?? ""),
1340
- },
1341
- from: `worker:${opts.workerId}`,
1342
- };
1338
+ if (shouldEmitDirectSessionJobEvent({ ok: result.ok, statusPersistedToServer })) {
1339
+ const eventCmd = result.ok
1340
+ ? {
1341
+ type: "job_completed" as const,
1342
+ payload: {
1343
+ jobId: job.id,
1344
+ summary: result.summary,
1345
+ artifacts: result.stdout
1346
+ ? [{ kind: "log" as const, text: result.stdout }]
1347
+ : undefined,
1348
+ },
1349
+ from: `worker:${opts.workerId}`,
1350
+ }
1351
+ : {
1352
+ type: "job_failed" as const,
1353
+ payload: {
1354
+ jobId: job.id,
1355
+ message: result.summary,
1356
+ detail: redactSensitiveText(result.stderr ?? ""),
1357
+ },
1358
+ from: `worker:${opts.workerId}`,
1359
+ };
1343
1360
 
1344
- await sendCommand(opts.server, job.sessionId, headers, eventCmd);
1361
+ await sendCommand(opts.server, job.sessionId, headers, eventCmd);
1362
+ }
1345
1363
  }
1346
1364
  } finally {
1347
1365
  clearInterval(busyHeartbeat);