@tokenfactory/acc-runner 0.14.0 → 0.18.0

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.
@@ -22,7 +22,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
22
22
  import { execa } from "execa";
23
23
  import { loadProfile as defaultLoadProfile } from "./config.js";
24
24
  import { normalizeUsage, parseClaudeJson, priceUsdCents, toCliAlias, } from "./cost-pricing.js";
25
- import { classifyClaudeExit } from "./failure-classifier.js";
25
+ import { classifyClaudeExit, extractResetTime, } from "./failure-classifier.js";
26
26
  import { startTaskGithubBudget, endTaskGithubBudget, } from "./github-client.js";
27
27
  import { git as defaultGit } from "./git.js";
28
28
  import { gh as defaultGh } from "./gh.js";
@@ -33,6 +33,7 @@ import { acquireTaskLock as defaultAcquireTaskLock, TaskLockHeldError, } from ".
33
33
  import { prepareTaskWorktree as defaultPrepareTaskWorktree, } from "./runtime/worktree.js";
34
34
  import { parseConflictMeta, resolveConflict, MAX_CONFLICT_ATTEMPTS, } from "./runtime/conflict-resolver.js";
35
35
  import { parseReworkMeta, renderReworkPrompt, fetchPrHeadRef as defaultFetchPrHeadRef, } from "./runtime/rework.js";
36
+ import { assertResolutionIntegrity, isConflictResolutionTask, summariseFailures, } from "./runtime/resolution-integrity.js";
36
37
  const LOG_BATCH_BYTES = 4 * 1024;
37
38
  /**
38
39
  * v0.6.0 REG-296: build the argv for `claude --print`. Splitting this
@@ -85,6 +86,85 @@ export function expandHomePath(p) {
85
86
  return path.join(os.homedir(), p.slice(2));
86
87
  return p;
87
88
  }
89
+ /**
90
+ * v0.63 T-63-1: default claude health probe. A silent instant-empty exit is
91
+ * ambiguous — it can be a broken host OR a momentarily-unavailable claude.
92
+ * `claude --version` is a cheap, deterministic answer to "is the binary/host
93
+ * actually broken?": if it returns 0 the host is fine (the exit was
94
+ * transient → claude_unavailable, retry), and any spawn failure / non-zero
95
+ * exit / timeout means the binary or its dynamic deps are genuinely broken
96
+ * (→ env_broken, quarantine). Short timeout so a probe never wedges the run.
97
+ */
98
+ async function defaultHealthProbe() {
99
+ try {
100
+ const res = await execa("claude", ["--version"], {
101
+ reject: false,
102
+ timeout: 5_000,
103
+ env: process.env,
104
+ });
105
+ if (res.exitCode === 0) {
106
+ return { ok: true, detail: (res.stdout || "").trim().slice(0, 200) || "claude --version ok" };
107
+ }
108
+ return {
109
+ ok: false,
110
+ detail: `claude --version exited ${res.exitCode ?? "signal"}: ${(res.stderr || "").trim().slice(0, 200)}`,
111
+ };
112
+ }
113
+ catch (err) {
114
+ return { ok: false, detail: `claude --version spawn failed: ${err.message}` };
115
+ }
116
+ }
117
+ const DEFAULT_CLAUDE_UNAVAILABLE_KNOBS = {
118
+ windowMs: 15 * 60_000,
119
+ alertThreshold: 5,
120
+ backoffBaseMs: 30_000,
121
+ backoffMaxMs: 15 * 60_000,
122
+ };
123
+ // Process-level state. runTask() is a fresh closure per task, but the module
124
+ // is loaded once, so consecutive task runs in the same `acc-runner watch`
125
+ // process share this — the only place a cross-task streak can live without
126
+ // touching watch.ts (out of this track's file scope). A clean (exit-0) claude
127
+ // run resets it: claude has recovered.
128
+ let claudeUnavailableKnobs = { ...DEFAULT_CLAUDE_UNAVAILABLE_KNOBS };
129
+ let claudeUnavailableHits = [];
130
+ let claudeUnavailableStreak = 0;
131
+ /** v0.63 T-63-1: override the silent-unavailability knobs (used by tests). */
132
+ export function configureClaudeUnavailable(partial) {
133
+ claudeUnavailableKnobs = { ...claudeUnavailableKnobs, ...partial };
134
+ }
135
+ /** v0.63 T-63-1: reset both the streak and the knobs (used by tests). */
136
+ export function resetClaudeUnavailableState() {
137
+ claudeUnavailableHits = [];
138
+ claudeUnavailableStreak = 0;
139
+ claudeUnavailableKnobs = { ...DEFAULT_CLAUDE_UNAVAILABLE_KNOBS };
140
+ }
141
+ /**
142
+ * v0.63 T-63-1: record one silent instant-empty exit and compute the response
143
+ * (exponential backoff + whether the sustained-streak INFRA alert trips).
144
+ * Exported so the watch process and the unit suite share one definition.
145
+ */
146
+ export function recordClaudeUnavailable(now) {
147
+ claudeUnavailableStreak += 1;
148
+ claudeUnavailableHits.push(now);
149
+ const cutoff = now - claudeUnavailableKnobs.windowMs;
150
+ claudeUnavailableHits = claudeUnavailableHits.filter((t) => t >= cutoff);
151
+ const windowCount = claudeUnavailableHits.length;
152
+ const alert = windowCount >= claudeUnavailableKnobs.alertThreshold;
153
+ const expo = claudeUnavailableKnobs.backoffBaseMs * 2 ** (claudeUnavailableStreak - 1);
154
+ const backoffMs = Math.min(expo, claudeUnavailableKnobs.backoffMaxMs);
155
+ return {
156
+ streak: claudeUnavailableStreak,
157
+ windowCount,
158
+ alert,
159
+ backoffMs,
160
+ resumeAt: new Date(now + backoffMs).toISOString(),
161
+ };
162
+ }
163
+ /** v0.63 T-63-1: a clean claude run proves the binary recovered — reset. */
164
+ function clearClaudeUnavailableStreak() {
165
+ claudeUnavailableStreak = 0;
166
+ claudeUnavailableHits = [];
167
+ }
88
168
  // v0.37-A HEAD-LOCK-GUARD: git worktree add/remove/prune can leave stale
89
169
  // .git/HEAD.lock or .git/index.lock behind. The virtiofs boundary keeps
90
170
  // the sandbox from clearing them, but the host runner process can. Call
@@ -447,6 +527,7 @@ export function runTask(taskId, deps) {
447
527
  ((args) => defaultPostRunnerStateMessage(deps.supabase, args));
448
528
  const loadProfile = deps.loadProfile ?? defaultLoadProfile;
449
529
  const loadPromptPreludeFn = deps.loadPromptPrelude ?? loadPromptPrelude;
530
+ const healthProbe = deps.healthProbe ?? defaultHealthProbe;
450
531
  // Silence the unused-binding lint for `checkoutBase` — v0.11-F supersedes
451
532
  // the v0.6.0 REG-301 pre-spawn `git checkout <baseBranch>` with the
452
533
  // worktree's `add -B <branch> <path> <baseBranch>` start-point semantics,
@@ -746,6 +827,102 @@ export function runTask(taskId, deps) {
746
827
  // return that follows.
747
828
  let worktree = null;
748
829
  let workdir = repoPath;
830
+ // v0.57 T-57-2: conflict-resolution integrity guardrail. For tasks whose
831
+ // job is to resolve a merge conflict (the Claude/worktree path that the
832
+ // mechanical resolver hands off to when it bails), re-verify the
833
+ // resolution from the runner's own vantage point before pushing —
834
+ // zero conflict markers, typecheck green, and no silently-dropped /
835
+ // regressed version bump. On failure the resolution is REJECTED (no
836
+ // push, not marked resolved) and the task fails with a diagnostic that
837
+ // names the offending assertion, so a broken merge is reworked rather
838
+ // than shipped. A no-op for every non-conflict task.
839
+ const runResolutionIntegrityGate = async () => {
840
+ if (!isConflictResolutionTask(task))
841
+ return null;
842
+ const assertIntegrity = deps.assertResolutionIntegrity ?? assertResolutionIntegrity;
843
+ let result;
844
+ try {
845
+ result = await assertIntegrity(workdir, {
846
+ baseRef: integrationBranch,
847
+ typecheckCmd: task.typecheck_cmd ?? undefined,
848
+ // The #496 regression lived in the acc-runner package; also guard
849
+ // the repo root. Each pair is skipped silently when its version is
850
+ // unchanged or unreadable, so this never false-fails.
851
+ versionTargets: [
852
+ { packageJsonPath: "package.json", changelogPath: "CHANGELOG.md" },
853
+ {
854
+ packageJsonPath: "packages/acc-runner/package.json",
855
+ changelogPath: "packages/acc-runner/CHANGELOG.md",
856
+ },
857
+ ],
858
+ });
859
+ }
860
+ catch (err) {
861
+ // A guardrail that itself crashed must fail closed: reject, never
862
+ // wave a resolution through because the check errored.
863
+ result = {
864
+ ok: false,
865
+ checked: [],
866
+ failures: [
867
+ {
868
+ assertion: "typecheck",
869
+ detail: `integrity check crashed: ${err.message?.slice(0, 200)}`,
870
+ },
871
+ ],
872
+ };
873
+ }
874
+ if (result.ok) {
875
+ await appendEvent(deps.supabase, taskId, "resolution_integrity_passed", {
876
+ pr_number: task.pr_number ?? null,
877
+ checked: result.checked,
878
+ });
879
+ return null;
880
+ }
881
+ const summary = summariseFailures(result.failures);
882
+ const detail = result.failures
883
+ .map((f) => `${f.assertion}: ${f.detail}`)
884
+ .join("\n");
885
+ await appendEvent(deps.supabase, taskId, "resolution_integrity_failed", {
886
+ pr_number: task.pr_number ?? null,
887
+ failed_assertions: result.failures.map((f) => f.assertion),
888
+ files: result.failures.flatMap((f) => f.files ?? []),
889
+ detail: detail.slice(-4000),
890
+ });
891
+ // Best-effort operator-facing activity event naming the failed assertion.
892
+ try {
893
+ await deps.supabase.rpc("log_activity", {
894
+ p_verb: "conflict.integrity_rejected",
895
+ p_target_id: String(task.pr_number ?? taskId),
896
+ p_payload: {
897
+ task_id: taskId,
898
+ repo: task.repo ?? null,
899
+ failed_assertions: result.failures.map((f) => f.assertion),
900
+ },
901
+ p_target_type: task.pr_number ? "pr" : "task",
902
+ });
903
+ }
904
+ catch {
905
+ /* best-effort */
906
+ }
907
+ await postState_("blocked", "error_context", {
908
+ task_id: taskId,
909
+ phase: "review",
910
+ error_class: "resolution_integrity_failed",
911
+ stderr_tail: detail.slice(-4000),
912
+ });
913
+ // Reject: do NOT push, do NOT mark resolved. Fail the task so the
914
+ // resolution is reworked instead of merged broken.
915
+ await deps.supabase.rpc("transition_task", {
916
+ p_task_id: taskId,
917
+ p_new_status: "failed",
918
+ });
919
+ return {
920
+ taskId,
921
+ status: "failed",
922
+ phase: "push",
923
+ error: `conflict-resolution integrity guardrail rejected resolution (${summary})`,
924
+ };
925
+ };
749
926
  try {
750
927
  // 3. Repo prep. v0.11-F: fetch on the shared clone (worktree
751
928
  // shares the object store) then provision an isolated worktree
@@ -926,32 +1103,35 @@ export function runTask(taskId, deps) {
926
1103
  durationMs: Date.now() - spawnedAt,
927
1104
  };
928
1105
  };
929
- // v0.53 T-53-4: quarantine tune. A lone instant-empty exit (the pd36
930
- // fast-exit heuristic) is too weak a signal to bench a healthy box —
931
- // v0.52 incident (d) quarantined a working machine on ONE 249ms empty
932
- // exit. So when the FIRST exit is a *heuristic* env_broken we log it
933
- // and retry the spawn once; only TWO consecutive instant-empty exits
934
- // are treated as a real env_broken. Definitive failures (dyld/OOM/exit
935
- // 127, usage/auth patterns — classified.heuristic falsey) skip the
936
- // retry and quarantine immediately, unchanged from v0.48.
1106
+ // v0.53 T-53-4 / v0.63 T-63-1: instant-empty tune. A lone instant-empty
1107
+ // exit (the pd36 fast-exit fingerprint, now classified
1108
+ // `claude_unavailable`) is too weak a signal to act on — v0.52 incident
1109
+ // (d) quarantined a working machine on ONE 249ms empty exit. So when the
1110
+ // FIRST exit is a *heuristic* claude_unavailable we log it and retry the
1111
+ // spawn once; a single blip that the retry recovers stays silent. Only a
1112
+ // CONFIRMED streak (two consecutive instant-empties) is acted on — and
1113
+ // even then v0.63 routes it to pause + backoff + retry (NOT quarantine),
1114
+ // gated by the health probe below. Definitive failures (dyld/OOM/exit
1115
+ // 127, usage/auth patterns — classified.heuristic falsey) skip the retry
1116
+ // and quarantine immediately, unchanged from v0.48.
937
1117
  let attempt = await runClaudeOnce();
938
1118
  const classifyIfFailed = (a) => a.exitCode === 0
939
1119
  ? null
940
1120
  : classifyClaudeExit(a.exitCode, a.stderr, a.stdout, a.durationMs);
941
1121
  let classified = classifyIfFailed(attempt);
942
- let instantEmptyStreak = classified?.class === "env_broken" && classified.heuristic ? 1 : 0;
1122
+ let instantEmptyStreak = classified?.class === "claude_unavailable" && classified.heuristic ? 1 : 0;
943
1123
  if (instantEmptyStreak === 1 && !cancelled) {
944
1124
  await appendEvent(deps.supabase, taskId, "log", {
945
1125
  phase: "claude_exit",
946
1126
  stream: "stderr",
947
- event: "env_broken_blip",
1127
+ event: "claude_unavailable_blip",
948
1128
  attempt: 1,
949
1129
  duration_ms: attempt.durationMs,
950
- message: "instant empty claude exit — single blip, retrying task once before quarantine",
1130
+ message: "instant empty claude exit — single blip, retrying task once before pausing",
951
1131
  });
952
1132
  attempt = await runClaudeOnce();
953
1133
  classified = classifyIfFailed(attempt);
954
- if (classified?.class === "env_broken" && classified.heuristic) {
1134
+ if (classified?.class === "claude_unavailable" && classified.heuristic) {
955
1135
  instantEmptyStreak = 2;
956
1136
  }
957
1137
  }
@@ -974,15 +1154,146 @@ export function runTask(taskId, deps) {
974
1154
  }
975
1155
  if (attempt.exitCode !== 0) {
976
1156
  // v0.48: classify the failure so the quarantine cause is consistent
977
- // across the event, the bus message, and the RunTaskOutcome.
978
- const cls = classified ??
1157
+ // across the event, the bus message, and the RunTaskOutcome. `let`
1158
+ // because v0.63's health probe can upgrade a claude_unavailable exit
1159
+ // to env_broken when the probe proves the host is genuinely broken.
1160
+ let cls = classified ??
979
1161
  classifyClaudeExit(attempt.exitCode, attempt.stderr, attempt.stdout, attempt.durationMs);
980
- // v0.53 T-53-4: a heuristic env_broken that did NOT reach 2
981
- // consecutive instant-empty exits is a transient blip, not a benched
982
- // machine — drop the quarantine cause so watch.ts handles it as a
983
- // normal failed task instead of quarantining the runner.
984
- const softUnconfirmed = cls.class === "env_broken" && cls.heuristic === true && instantEmptyStreak < 2;
985
- const quarantineCause = cls.class !== "task_error" && !softUnconfirmed ? cls.class : undefined;
1162
+ // v0.56 (T-56-1): capacity exhaustion is NOT a task failure. Do not
1163
+ // transition the task to 'failed' (that would dead-letter it and burn
1164
+ // the reviewer/automerge retry cap). Leave it 'running' with its
1165
+ // signal loop stopped (the outer finally does this) so the
1166
+ // stale-running sweep returns it to 'queued' with runner_id cleared —
1167
+ // a lossless requeue for any runner that still has capacity. Surface
1168
+ // the parsed reset time so watch.ts can pause until the window
1169
+ // reopens. The locks are released by the outer finally exactly as on
1170
+ // any other exit path.
1171
+ if (cls.class === "capacity_exhausted") {
1172
+ const resumeMs = extractResetTime(`${attempt.stderr}\n${attempt.stdout}`);
1173
+ const resumeAt = resumeMs !== null ? new Date(resumeMs).toISOString() : null;
1174
+ await appendEvent(deps.supabase, taskId, "capacity_paused", {
1175
+ phase: "claude_exit",
1176
+ exit_code: attempt.exitCode,
1177
+ exit_class: cls.class,
1178
+ resume_at: resumeAt,
1179
+ detail: cls.detail,
1180
+ runner_id: deps.session.runnerId,
1181
+ });
1182
+ return {
1183
+ taskId,
1184
+ status: "capacity_paused",
1185
+ phase: "claude_exit",
1186
+ exitCode: attempt.exitCode,
1187
+ error: cls.detail,
1188
+ capacity_exhausted: true,
1189
+ resume_at: resumeAt,
1190
+ };
1191
+ }
1192
+ // v0.63 (T-63-1): a bare silent instant-empty exit. Silence alone
1193
+ // cannot tell "broken host" from "claude momentarily unavailable", so
1194
+ // run an authoritative `claude --version` health probe as the
1195
+ // tiebreaker. Probe OK → the host is fine → treat exactly like
1196
+ // capacity_exhausted (pause + EXPONENTIAL backoff + retry; leave the
1197
+ // task 'running' for the stale-running sweep; NEVER quarantine, NEVER
1198
+ // burn the task/review retry caps). Probe FAILS → the binary/host is
1199
+ // genuinely broken → upgrade to env_broken and fall through to the
1200
+ // quarantine path (the pd36 contract, now gated by a real signal
1201
+ // rather than silence). A human INFRA alert fires only after a
1202
+ // SUSTAINED streak inside a rolling window, never on 2.
1203
+ if (cls.class === "claude_unavailable") {
1204
+ const probe = await healthProbe();
1205
+ await appendEvent(deps.supabase, taskId, "log", {
1206
+ phase: "claude_exit",
1207
+ stream: "stderr",
1208
+ event: "claude_health_probe",
1209
+ ok: probe.ok,
1210
+ detail: probe.detail,
1211
+ consecutive_instant_empty: instantEmptyStreak,
1212
+ });
1213
+ if (probe.ok) {
1214
+ const decision = recordClaudeUnavailable(Date.now());
1215
+ await appendEvent(deps.supabase, taskId, "claude_unavailable", {
1216
+ phase: "claude_exit",
1217
+ exit_code: attempt.exitCode,
1218
+ exit_class: cls.class,
1219
+ consecutive_instant_empty: instantEmptyStreak,
1220
+ duration_ms: attempt.durationMs,
1221
+ resume_at: decision.resumeAt,
1222
+ streak: decision.streak,
1223
+ window_count: decision.windowCount,
1224
+ detail: cls.detail,
1225
+ runner_id: deps.session.runnerId,
1226
+ });
1227
+ // Best-effort audit trail on the runner timeline.
1228
+ try {
1229
+ await deps.supabase.rpc("log_activity", {
1230
+ p_verb: "runner.claude_unavailable",
1231
+ p_target_id: deps.session.runnerId,
1232
+ p_target_type: "runner",
1233
+ p_payload: {
1234
+ task_id: taskId,
1235
+ streak: decision.streak,
1236
+ window_count: decision.windowCount,
1237
+ backoff_ms: decision.backoffMs,
1238
+ resume_at: decision.resumeAt,
1239
+ detail: cls.detail,
1240
+ },
1241
+ });
1242
+ }
1243
+ catch { /* best-effort */ }
1244
+ // Escalate to a human only on a SUSTAINED streak — not a single
1245
+ // pause, not 2. Still NOT a quarantine: the runner stays online,
1246
+ // paused + retrying, while an operator investigates infra.
1247
+ if (decision.alert) {
1248
+ try {
1249
+ await deps.supabase.rpc("log_activity", {
1250
+ p_verb: "runner.claude_unavailable_infra",
1251
+ p_target_id: deps.session.runnerId,
1252
+ p_target_type: "runner",
1253
+ p_payload: {
1254
+ task_id: taskId,
1255
+ window_count: decision.windowCount,
1256
+ streak: decision.streak,
1257
+ detail: cls.detail,
1258
+ },
1259
+ });
1260
+ }
1261
+ catch { /* best-effort */ }
1262
+ await postState_("blocked", "error_context", {
1263
+ task_id: taskId,
1264
+ phase: "code",
1265
+ error_class: "claude_unavailable_sustained",
1266
+ infra_alert: true,
1267
+ window_count: decision.windowCount,
1268
+ stderr_tail: cls.detail,
1269
+ });
1270
+ }
1271
+ return {
1272
+ taskId,
1273
+ status: "capacity_paused",
1274
+ phase: "claude_exit",
1275
+ exitCode: attempt.exitCode,
1276
+ error: `claude silently unavailable (instant-empty exit) — pausing ` +
1277
+ `${Math.round(decision.backoffMs / 1000)}s then retrying, not quarantining`,
1278
+ claude_unavailable: true,
1279
+ resume_at: decision.resumeAt,
1280
+ };
1281
+ }
1282
+ // Probe failed — the host/binary really is broken. This IS an
1283
+ // env_broken; fall through to the quarantine path below.
1284
+ cls = {
1285
+ exitCode: attempt.exitCode,
1286
+ class: "env_broken",
1287
+ detail: `${cls.detail}; claude health probe failed: ${probe.detail}`,
1288
+ };
1289
+ }
1290
+ // v0.48 / v0.63: by this point claude_unavailable has either returned
1291
+ // (probe OK) or been rewritten to env_broken (probe failed), so any
1292
+ // non-task_error class is a genuine quarantine cause. consecutive
1293
+ // mirrors the instant-empty streak for an env_broken upgraded from the
1294
+ // heuristic (≥1), so quarantine.json still records 1 (definitive) vs 2
1295
+ // (heuristic-confirmed).
1296
+ const quarantineCause = cls.class !== "task_error" ? cls.class : undefined;
986
1297
  const quarantineConsecutive = quarantineCause === "env_broken" ? Math.max(instantEmptyStreak, 1) : undefined;
987
1298
  await appendEvent(deps.supabase, taskId, "error", {
988
1299
  phase: "claude_exit",
@@ -1020,6 +1331,10 @@ export function runTask(taskId, deps) {
1020
1331
  // Success: expose the final attempt's stdout under the name the
1021
1332
  // downstream report/PR path expects.
1022
1333
  const capturedStdout = attempt.stdout;
1334
+ // v0.63 (T-63-1): a clean claude run proves the binary recovered — reset
1335
+ // the silent-unavailability streak so the next blip restarts the backoff
1336
+ // ladder from the base and the rolling INFRA-alert window starts fresh.
1337
+ clearClaudeUnavailableStreak();
1023
1338
  // v0.14-MESSAGING-RUNTIME-WIRE: Claude finished successfully.
1024
1339
  // testing covers the post-exit window where the runner inspects
1025
1340
  // the captured stdout (parses the report, builds the cost event)
@@ -1036,6 +1351,10 @@ export function runTask(taskId, deps) {
1036
1351
  // push failure here: a rework push that didn't land means the
1037
1352
  // reviewer feedback was NOT addressed, so the task must fail.
1038
1353
  if (reworkMeta) {
1354
+ // v0.57 T-57-2: gate conflict-resolution reworks before the push.
1355
+ const reworkIntegrity = await runResolutionIntegrityGate();
1356
+ if (reworkIntegrity)
1357
+ return reworkIntegrity;
1039
1358
  try {
1040
1359
  await git.push(workdir, branch);
1041
1360
  }
@@ -1117,6 +1436,11 @@ export function runTask(taskId, deps) {
1117
1436
  // Emit the first-commit SLO signal once, before push, so the funnel
1118
1437
  // sees "coding started" independent of whether the push/PR succeeds.
1119
1438
  await emitFirstCommit(deps.supabase, git, taskId, workdir, integrationBranch, branch);
1439
+ // v0.57 T-57-2: gate conflict-resolution tasks before the push so a
1440
+ // broken merge never reaches a PR. No-op for every non-conflict task.
1441
+ const integrityGate = await runResolutionIntegrityGate();
1442
+ if (integrityGate)
1443
+ return integrityGate;
1120
1444
  // 5. Push + open PR. Both run from the worktree so the operator's
1121
1445
  // shared clone never has the task branch checked out.
1122
1446
  let prUrl = "";