@pushpalsdev/cli 1.1.15 → 1.1.17
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/dist/pushpals-cli.js +48 -7
- package/package.json +1 -1
- package/runtime/sandbox/.pushpals-remotebuddy-fallback.js +15 -12
- package/runtime/sandbox/apps/workerpals/src/docker_executor.ts +91 -15
- package/runtime/sandbox/apps/workerpals/src/execute_job.ts +117 -8
- package/runtime/sandbox/apps/workerpals/src/job_runner.ts +11 -2
- package/runtime/sandbox/apps/workerpals/src/workerpals_main.ts +18 -2
package/dist/pushpals-cli.js
CHANGED
|
@@ -1637,9 +1637,11 @@ 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;
|
|
1644
|
+
var DEFAULT_WORKERPAL_STARTUP_STATUS_FETCH_TIMEOUT_MS = 2000;
|
|
1643
1645
|
var WINDOWS_TASKKILL_TIMEOUT_MS2 = 5000;
|
|
1644
1646
|
var RUNTIME_BINARY_DOWNLOAD_ATTEMPTS = 3;
|
|
1645
1647
|
var DEFAULT_STARTUP_GIT_PROBE_TIMEOUT_MS = 5000;
|
|
@@ -1647,6 +1649,7 @@ var DEFAULT_STARTUP_GIT_REMOTE_TIMEOUT_MS = 1e4;
|
|
|
1647
1649
|
var DEFAULT_EMBEDDED_SERVICE_LAUNCH_WARN_MS = 5000;
|
|
1648
1650
|
var EMBEDDED_SERVICE_RESTART_MAX_ATTEMPTS = 4;
|
|
1649
1651
|
var WORKERPAL_STARTUP_READINESS_PROBE_MAX_MS = 15000;
|
|
1652
|
+
var BLOCKING_WORKERPAL_IMAGE_BUILD_ENV = "PUSHPALS_BLOCKING_WORKERPAL_IMAGE_BUILD";
|
|
1650
1653
|
var CLI_SESSION_JOB_LOG_MAX_CHARS = 700;
|
|
1651
1654
|
var CLI_SESSION_SHOW_JOB_EVENTS_ENV = "PUSHPALS_CLI_SHOW_JOB_EVENTS";
|
|
1652
1655
|
var EMBEDDED_RUNTIME_SAFETY_CAP_DISABLE_ENV = "PUSHPALS_DISABLE_EMBEDDED_SAFETY_CAPS";
|
|
@@ -3450,7 +3453,7 @@ function isWorkerpalEphemeralWorktreePath(repoRoot, worktreePath) {
|
|
|
3450
3453
|
if (!normalizedPath.startsWith(expectedPrefix))
|
|
3451
3454
|
return false;
|
|
3452
3455
|
const leaf = basename(normalizedPath);
|
|
3453
|
-
return /^(job|selfcheck)
|
|
3456
|
+
return /^(job|selfcheck)-[a-z0-9][a-z0-9._-]*$/i.test(leaf);
|
|
3454
3457
|
}
|
|
3455
3458
|
function resolveConfiguredDockerExecutable(env, platform = process.platform) {
|
|
3456
3459
|
const configured = String(env.PUSHPALS_DOCKER_BIN_ABSOLUTE ?? env.PUSHPALS_DOCKER_BIN ?? (platform === "win32" ? "docker.exe" : "docker")).trim();
|
|
@@ -3899,6 +3902,13 @@ function resolveWorkerpalCapacityTimeoutMs(config) {
|
|
|
3899
3902
|
function resolveWorkerpalStartupReadinessProbeTimeoutMs(config) {
|
|
3900
3903
|
return Math.max(5000, Math.min(resolveWorkerpalCapacityTimeoutMs(config), WORKERPAL_STARTUP_READINESS_PROBE_MAX_MS));
|
|
3901
3904
|
}
|
|
3905
|
+
function shouldPrepareEmbeddedWorkerpalDockerImageBlocking(opts = {}) {
|
|
3906
|
+
const env = opts.env ?? process.env;
|
|
3907
|
+
const explicit = String(env[BLOCKING_WORKERPAL_IMAGE_BUILD_ENV] ?? "").trim();
|
|
3908
|
+
if (explicit)
|
|
3909
|
+
return isTruthyCliEnvValue(explicit);
|
|
3910
|
+
return (opts.platform ?? process.platform) !== "win32";
|
|
3911
|
+
}
|
|
3902
3912
|
function shouldRunEmbeddedRuntimeStartupPrechecks(opts) {
|
|
3903
3913
|
return !opts.serverHealthy && !opts.noAutoStart;
|
|
3904
3914
|
}
|
|
@@ -4248,6 +4258,16 @@ function extractRemoteBuddySessionConsumerHealth(statusPayload, sessionId) {
|
|
|
4248
4258
|
detail: `No connected RemoteBuddy session consumer found for session ${sessionId}`
|
|
4249
4259
|
};
|
|
4250
4260
|
}
|
|
4261
|
+
function shouldDeferRemoteBuddySessionConsumerReadiness(opts) {
|
|
4262
|
+
if (opts.localBuddyEnabled)
|
|
4263
|
+
return false;
|
|
4264
|
+
if (opts.remoteBuddyReady)
|
|
4265
|
+
return false;
|
|
4266
|
+
if (!opts.remoteBuddyServiceRunning)
|
|
4267
|
+
return false;
|
|
4268
|
+
const startupGraceMs = Math.max(0, opts.startupGraceMs ?? DEFAULT_REMOTEBUDDY_CONSUMER_STARTUP_GRACE_MS);
|
|
4269
|
+
return opts.readinessElapsedMs >= startupGraceMs;
|
|
4270
|
+
}
|
|
4251
4271
|
async function probeRemoteBuddySessionConsumer(serverUrl, sessionId) {
|
|
4252
4272
|
try {
|
|
4253
4273
|
const response = await fetchWithTimeout(`${serverUrl}/system/status`, {}, 1e4);
|
|
@@ -4276,8 +4296,8 @@ async function probeSourceControlManager(port) {
|
|
|
4276
4296
|
return false;
|
|
4277
4297
|
}
|
|
4278
4298
|
}
|
|
4279
|
-
async function fetchWorkerStatusRows(serverUrl, ttlMs) {
|
|
4280
|
-
const payload = await fetchJsonWithTimeout(`${serverUrl}/workers?ttlMs=${Math.max(1000, Math.floor(ttlMs))}`, {},
|
|
4299
|
+
async function fetchWorkerStatusRows(serverUrl, ttlMs, timeoutMs = 1e4) {
|
|
4300
|
+
const payload = await fetchJsonWithTimeout(`${serverUrl}/workers?ttlMs=${Math.max(1000, Math.floor(ttlMs))}`, {}, Math.max(250, Math.floor(timeoutMs)));
|
|
4281
4301
|
if (!payload?.ok || !Array.isArray(payload.workers)) {
|
|
4282
4302
|
return [];
|
|
4283
4303
|
}
|
|
@@ -4287,7 +4307,8 @@ async function waitForWorkerpalCapacity(opts) {
|
|
|
4287
4307
|
const deadline = Date.now() + Math.max(1000, opts.timeoutMs);
|
|
4288
4308
|
let lastObservedOnline = 0;
|
|
4289
4309
|
while (Date.now() < deadline) {
|
|
4290
|
-
const
|
|
4310
|
+
const remainingMs = Math.max(250, deadline - Date.now());
|
|
4311
|
+
const workers = await (opts.fetchWorkersFn ?? fetchWorkerStatusRows)(opts.serverUrl, opts.ttlMs, Math.min(DEFAULT_WORKERPAL_STARTUP_STATUS_FETCH_TIMEOUT_MS, remainingMs));
|
|
4291
4312
|
const summary = summarizeWorkerStatusRows(workers);
|
|
4292
4313
|
if (summary.onlineWorkers > 0) {
|
|
4293
4314
|
lastObservedOnline = Math.max(lastObservedOnline, summary.onlineWorkers);
|
|
@@ -4716,6 +4737,7 @@ ${tail}` : ""}`);
|
|
|
4716
4737
|
const optionalServiceExitWarned = new Set;
|
|
4717
4738
|
let lastReadinessWaitLogAt = 0;
|
|
4718
4739
|
let lastReadinessWaitDetail = "";
|
|
4740
|
+
let deferredRemoteBuddyConsumerLogged = false;
|
|
4719
4741
|
while (Date.now() < deadline) {
|
|
4720
4742
|
reportRemoteBuddyAutonomousEngineState();
|
|
4721
4743
|
if (maybeActivateRemoteBuddyWindowsFallback("silent_startup")) {
|
|
@@ -4759,7 +4781,15 @@ ${tail}` : ""}`);
|
|
|
4759
4781
|
}
|
|
4760
4782
|
const health = localBuddyEnabled ? await probeLocalBuddy(opts.localAgentUrl) : null;
|
|
4761
4783
|
const remoteBuddyHealth2 = await probeRemoteBuddySessionConsumer(opts.serverUrl, opts.sessionId);
|
|
4762
|
-
|
|
4784
|
+
const remoteBuddyServiceRunning = serviceManager.getServices().some((service) => service.name === "remotebuddy" && !service.exited);
|
|
4785
|
+
const deferRemoteBuddyConsumer = shouldDeferRemoteBuddySessionConsumerReadiness({
|
|
4786
|
+
localBuddyEnabled,
|
|
4787
|
+
remoteBuddyReady: remoteBuddyHealth2.ok,
|
|
4788
|
+
remoteBuddyServiceRunning,
|
|
4789
|
+
readinessElapsedMs: Date.now() - readinessPhaseStartedAt
|
|
4790
|
+
});
|
|
4791
|
+
const remoteBuddyReadyForCli = remoteBuddyHealth2.ok || deferRemoteBuddyConsumer;
|
|
4792
|
+
if (localBuddyEnabled && !health?.ok || !remoteBuddyReadyForCli) {
|
|
4763
4793
|
const localBuddyDetail = localBuddyEnabled ? health?.ok ? "LocalBuddy ready" : "LocalBuddy not ready" : "LocalBuddy skipped";
|
|
4764
4794
|
const readinessDetail = `${localBuddyDetail}; ${remoteBuddyHealth2.detail}`;
|
|
4765
4795
|
const now = Date.now();
|
|
@@ -4770,7 +4800,11 @@ ${tail}` : ""}`);
|
|
|
4770
4800
|
lastReadinessWaitLogAt = now;
|
|
4771
4801
|
}
|
|
4772
4802
|
}
|
|
4773
|
-
if ((!localBuddyEnabled || health?.ok) &&
|
|
4803
|
+
if ((!localBuddyEnabled || health?.ok) && remoteBuddyReadyForCli) {
|
|
4804
|
+
if (deferRemoteBuddyConsumer && !deferredRemoteBuddyConsumerLogged) {
|
|
4805
|
+
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}).`);
|
|
4806
|
+
deferredRemoteBuddyConsumerLogged = true;
|
|
4807
|
+
}
|
|
4774
4808
|
reportRemoteBuddyAutonomousEngineState();
|
|
4775
4809
|
const stabilityDeadline = Date.now() + DEFAULT_SERVICE_STABILITY_GRACE_MS;
|
|
4776
4810
|
while (Date.now() < stabilityDeadline) {
|
|
@@ -5702,7 +5736,10 @@ async function main() {
|
|
|
5702
5736
|
console.error("[pushpals] Precheck failed: start Docker Desktop or the Docker daemon, then retry pushpals.");
|
|
5703
5737
|
process.exit(1);
|
|
5704
5738
|
}
|
|
5705
|
-
if (runEmbeddedRuntimeStartupPrechecks && workerpalDockerPrecheck.status !== "failed"
|
|
5739
|
+
if (runEmbeddedRuntimeStartupPrechecks && workerpalDockerPrecheck.status !== "failed" && shouldPrepareEmbeddedWorkerpalDockerImageBlocking({
|
|
5740
|
+
platform: process.platform,
|
|
5741
|
+
env: process.env
|
|
5742
|
+
})) {
|
|
5706
5743
|
const workerpalImagePrecheck = await prepareEmbeddedWorkerpalDockerImageIfNeeded({
|
|
5707
5744
|
preparedRuntime,
|
|
5708
5745
|
config,
|
|
@@ -5716,6 +5753,8 @@ async function main() {
|
|
|
5716
5753
|
if (workerpalImagePrecheck.runtimeTag) {
|
|
5717
5754
|
resolvedRuntimeTagForAutoStart = workerpalImagePrecheck.runtimeTag;
|
|
5718
5755
|
}
|
|
5756
|
+
} else if (runEmbeddedRuntimeStartupPrechecks && workerpalDockerPrecheck.status !== "failed") {
|
|
5757
|
+
console.log(`[pushpals] Skipping blocking WorkerPal sandbox image build during CLI startup; WorkerPal warmup will prepare it in the background. Set ${BLOCKING_WORKERPAL_IMAGE_BUILD_ENV}=1 to force the old foreground behavior.`);
|
|
5719
5758
|
}
|
|
5720
5759
|
let remoteBuddyConsumerHealth = {
|
|
5721
5760
|
ok: false,
|
|
@@ -6040,6 +6079,8 @@ export {
|
|
|
6040
6079
|
shouldShowCliSessionOperationalEvents,
|
|
6041
6080
|
shouldRunEmbeddedRuntimeStartupPrechecks,
|
|
6042
6081
|
shouldRestartEmbeddedService,
|
|
6082
|
+
shouldPrepareEmbeddedWorkerpalDockerImageBlocking,
|
|
6083
|
+
shouldDeferRemoteBuddySessionConsumerReadiness,
|
|
6043
6084
|
runCommandWithEnv,
|
|
6044
6085
|
resolveWorkerpalDockerProbe,
|
|
6045
6086
|
resolveWorkerExecutionReadiness,
|
package/package.json
CHANGED
|
@@ -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 :
|
|
7375
|
-
const ideationStateTraits = snapshot.state_traits.slice(0, reduced ? 6 :
|
|
7376
|
-
const ideationFeedbackPriors = snapshot.feedback_priors.slice(0, reduced ? 4 :
|
|
7377
|
-
const ideationEngineIdeaPriors = (snapshot.engine_idea_priors ?? []).slice(0, reduced ? 4 :
|
|
7378
|
-
const ideationOpenObjectives = snapshot.open_objectives.slice(0, reduced ? 4 :
|
|
7379
|
-
const ideationActiveCooldowns = snapshot.active_cooldowns.slice(0, reduced ? 4 :
|
|
7380
|
-
const ideationRepoTargets = repoTargets.slice(0, reduced ? 4 :
|
|
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 ?
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1219
|
-
|
|
1220
|
-
|
|
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
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
|
1843
|
-
const
|
|
1844
|
-
|
|
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
|
-
|
|
1605
|
-
|
|
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)
|
|
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) =>
|
|
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:
|
|
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
|
|
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("
|
|
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
|
-
`
|
|
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:
|
|
1553
|
+
summary: `Job execution failed before completion: ${errorSummary}`,
|
|
1538
1554
|
stderr: String(err),
|
|
1539
1555
|
...(cooldownAfterJobMs > 0 ? { cooldownMs: cooldownAfterJobMs } : {}),
|
|
1540
1556
|
};
|