@pushpalsdev/cli 1.0.39 → 1.0.40

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.39",
3
+ "version": "1.0.40",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -240,6 +240,7 @@ export class DockerExecutor {
240
240
  private lastLoggedExecutionConfig = "";
241
241
  private lastLoggedEndpointRewrite = "";
242
242
  private warmedBackends = new Set<string>();
243
+ private preparedMergeConflictJobs = new Set<string>();
243
244
  private mergeConflictRefreshPromise: Promise<void> | null = null;
244
245
  private readonly config: WorkerpalsRuntimeConfig;
245
246
 
@@ -318,7 +319,6 @@ export class DockerExecutor {
318
319
  const worktreePath = resolve(this.worktreeDir, worktreeName);
319
320
 
320
321
  try {
321
- await this.ensureFreshImageForMergeConflictJob(job, onLog);
322
322
  const worktreeBaseRef = await this.resolveWorktreeBaseRefForJob(job, onLog);
323
323
  // Step 1: Create isolated git worktree
324
324
  await this.createWorktree(worktreePath, worktreeBaseRef);
@@ -398,6 +398,7 @@ export class DockerExecutor {
398
398
  stderr: `Retries exhausted after ${this.jobRetryMaxAttempts} attempts`,
399
399
  };
400
400
  } finally {
401
+ this.preparedMergeConflictJobs.delete(job.id);
401
402
  this.activeJobs = Math.max(0, this.activeJobs - 1);
402
403
  // Step 4: Clean up worktree (always cleanup)
403
404
  await this.removeWorktree(worktreePath).catch((err) => {
@@ -1690,6 +1691,22 @@ export class DockerExecutor {
1690
1691
  return resolutionType === "merge_conflict";
1691
1692
  }
1692
1693
 
1694
+ shouldPrepareMergeConflictJobBeforeExecution(job: Job): boolean {
1695
+ return this.isMergeConflictResolutionJob(job) && !this.preparedMergeConflictJobs.has(job.id);
1696
+ }
1697
+
1698
+ async prepareMergeConflictJobEnvironment(
1699
+ job: Job,
1700
+ onLog?: (stream: "stdout" | "stderr", line: string) => void,
1701
+ ): Promise<void> {
1702
+ await this.ensureFreshImageForMergeConflictJob(job, onLog);
1703
+ this.preparedMergeConflictJobs.add(job.id);
1704
+ }
1705
+
1706
+ recommendedMergeConflictDeferMs(): number {
1707
+ return Math.max(60_000, Math.min(this.options.timeoutMs, 5 * 60_000));
1708
+ }
1709
+
1693
1710
  private async ensureFreshImageForMergeConflictJob(
1694
1711
  job: Job,
1695
1712
  onLog?: (stream: "stdout" | "stderr", line: string) => void,
@@ -965,6 +965,40 @@ async function failActiveJobOnShutdown(
965
965
  }
966
966
  }
967
967
 
968
+ async function deferClaimedJobForMaintenance(
969
+ opts: ReturnType<typeof parseArgs>,
970
+ headers: Record<string, string>,
971
+ jobId: string,
972
+ deferMs: number,
973
+ ): Promise<{ ok: boolean; availableAt?: string; message?: string }> {
974
+ try {
975
+ const response = await postJsonWithTimeout(`${opts.server}/jobs/${jobId}/defer`, headers, {
976
+ workerId: opts.workerId,
977
+ deferMs,
978
+ });
979
+ const payload = (await response.json().catch(() => ({}))) as {
980
+ ok?: boolean;
981
+ availableAt?: string;
982
+ message?: string;
983
+ };
984
+ if (!response.ok || !payload.ok) {
985
+ return {
986
+ ok: false,
987
+ message: payload.message || `HTTP ${response.status}`,
988
+ };
989
+ }
990
+ return {
991
+ ok: true,
992
+ availableAt: payload.availableAt,
993
+ };
994
+ } catch (error) {
995
+ return {
996
+ ok: false,
997
+ message: error instanceof Error ? error.message : String(error),
998
+ };
999
+ }
1000
+ }
1001
+
968
1002
  async function workerLoop(
969
1003
  opts: ReturnType<typeof parseArgs>,
970
1004
  dockerExecutor: DockerExecutor | null,
@@ -1034,6 +1068,83 @@ async function workerLoop(
1034
1068
  const job = data.job;
1035
1069
 
1036
1070
  if (job) {
1071
+ if (
1072
+ dockerExecutor &&
1073
+ dockerExecutor.shouldPrepareMergeConflictJobBeforeExecution(job)
1074
+ ) {
1075
+ const deferMs = dockerExecutor.recommendedMergeConflictDeferMs();
1076
+ const deferred = await deferClaimedJobForMaintenance(opts, headers, job.id, deferMs);
1077
+ if (!deferred.ok) {
1078
+ console.warn(
1079
+ `[WorkerPals] Failed to defer merge-conflict job ${job.id} for image refresh; falling back to claimed execution path: ${
1080
+ deferred.message || "unknown error"
1081
+ }`,
1082
+ );
1083
+ } else {
1084
+ console.log(
1085
+ `[WorkerPals] Deferred merge-conflict job ${job.id} until ${
1086
+ deferred.availableAt ?? "maintenance complete"
1087
+ } while refreshing Docker image outside claimed-job lifetime.`,
1088
+ );
1089
+ const maintenanceHeartbeat = setInterval(() => {
1090
+ void transport.sendHeartbeat({
1091
+ ...buildHeartbeatPayload("idle", null),
1092
+ details: {
1093
+ repo: opts.repo,
1094
+ baseRef: opts.worktreeBaseRef,
1095
+ dockerImage: opts.docker ? opts.dockerImage : null,
1096
+ dockerNetworkMode: opts.docker ? opts.dockerNetworkMode : null,
1097
+ maintenance: "merge_conflict_image_refresh",
1098
+ deferredJobId: job.id,
1099
+ },
1100
+ });
1101
+ }, heartbeatEveryMs);
1102
+ try {
1103
+ await maybeHeartbeat("idle", null, true);
1104
+ await dockerExecutor.prepareMergeConflictJobEnvironment(job);
1105
+ } catch (error) {
1106
+ const detail = redactSensitiveText(
1107
+ error instanceof Error ? error.stack || error.message : String(error),
1108
+ );
1109
+ console.error(
1110
+ `[WorkerPals] Merge-conflict environment preparation failed for ${job.id}: ${detail}`,
1111
+ );
1112
+ try {
1113
+ const failResponse = await postJsonWithTimeout(
1114
+ `${opts.server}/jobs/${job.id}/fail-deferred`,
1115
+ headers,
1116
+ {
1117
+ workerId: opts.workerId,
1118
+ message: "Merge-conflict environment preparation failed",
1119
+ detail,
1120
+ },
1121
+ );
1122
+ const failPayload = (await failResponse.json().catch(() => ({}))) as {
1123
+ ok?: boolean;
1124
+ message?: string;
1125
+ };
1126
+ if (!failResponse.ok || !failPayload.ok) {
1127
+ console.error(
1128
+ `[WorkerPals] Failed to mark deferred job ${job.id} as failed: ${
1129
+ failPayload.message || `HTTP ${failResponse.status}`
1130
+ }`,
1131
+ );
1132
+ }
1133
+ } catch (failErr) {
1134
+ console.error(
1135
+ `[WorkerPals] Failed to mark deferred job ${job.id} as failed: ${
1136
+ failErr instanceof Error ? failErr.message : String(failErr)
1137
+ }`,
1138
+ );
1139
+ }
1140
+ } finally {
1141
+ clearInterval(maintenanceHeartbeat);
1142
+ }
1143
+ await maybeHeartbeat("idle", null, true);
1144
+ continue;
1145
+ }
1146
+ }
1147
+
1037
1148
  runtimeState.currentJobId = job.id;
1038
1149
  runtimeState.currentSessionId = job.sessionId ?? null;
1039
1150
  console.log(`[WorkerPals] Claimed job ${job.id} (${job.kind})`);