@runuai/host 0.2.3 → 0.2.4

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.
@@ -238,15 +238,19 @@ async function runRefresh(taskId: string, userId: string): Promise<void> {
238
238
  } catch (err) {
239
239
  const reason = err instanceof Error ? err.message : String(err);
240
240
  // A revoked / expired refresh token can't recover — drop it to force a
241
- // clean re-grant through the OAuth flow.
241
+ // clean re-grant through the OAuth flow. Anything else is likely transient
242
+ // (network / cloud blip) — self-heal with a bounded retry.
242
243
  if (/\b40[13]\b|revoked|invalid_grant|bad_refresh/i.test(reason)) {
243
244
  deleteToken(userId);
245
+ } else {
246
+ scheduleGithubRetry(taskId, userId, 0);
244
247
  }
245
248
  authExpiredHandler?.(taskId, userId, reason);
246
249
  }
247
250
  }
248
251
 
249
252
  export function clearRefresh(taskId: string): void {
253
+ clearGithubRetry(taskId);
250
254
  const t = timers.get(taskId);
251
255
  if (t) {
252
256
  clearTimeout(t);
@@ -254,6 +258,50 @@ export function clearRefresh(taskId: string): void {
254
258
  }
255
259
  }
256
260
 
261
+ // --- self-heal: retry a failed gh setup with bounded backoff ----------------
262
+ // A gh setup/refresh can fail transiently — e.g. the cloud token-exchange fetch
263
+ // times out while the host is briefly unreachable (as happened when the bridge
264
+ // was flapping). Without a retry the task is stranded on a stale/invalid token
265
+ // until someone runs /retry-gh, because ensureSessions won't re-inject once a
266
+ // channel's sessions are started. Retry with bounded backoff; cleared on
267
+ // success (clearGithubRetry) and on task teardown (via clearRefresh).
268
+ const RETRY_BACKOFF_MS = [5_000, 20_000, 60_000, 180_000, 300_000];
269
+ const retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
270
+
271
+ function clearGithubRetry(taskId: string): void {
272
+ const t = retryTimers.get(taskId);
273
+ if (t) {
274
+ clearTimeout(t);
275
+ retryTimers.delete(taskId);
276
+ }
277
+ }
278
+
279
+ function scheduleGithubRetry(
280
+ taskId: string,
281
+ userId: string,
282
+ attempt: number,
283
+ ): void {
284
+ clearGithubRetry(taskId);
285
+ if (attempt >= RETRY_BACKOFF_MS.length) {
286
+ console.warn(
287
+ `[github] task ${taskId}: gh setup still failing after ${attempt} attempts — giving up until /retry-gh`,
288
+ );
289
+ return;
290
+ }
291
+ const delay = RETRY_BACKOFF_MS[attempt] ?? 300_000;
292
+ const timer = setTimeout(() => {
293
+ retryTimers.delete(taskId);
294
+ void setupTaskGithub(taskId, userId, {}, attempt + 1);
295
+ }, delay);
296
+ timer.unref?.();
297
+ retryTimers.set(taskId, timer);
298
+ }
299
+
300
+ /** Test/diagnostic: is a gh-setup retry currently armed for this task? */
301
+ export function hasPendingGithubRetry(taskId: string): boolean {
302
+ return retryTimers.has(taskId);
303
+ }
304
+
257
305
  /**
258
306
  * Injectable seams for {@link setupTaskGithub} (mocked in tests so the decision
259
307
  * logic needs neither the host DB nor docker). All default to the real impls.
@@ -283,6 +331,7 @@ export async function setupTaskGithub(
283
331
  taskId: string,
284
332
  userId: string,
285
333
  deps: SetupTaskDeps = {},
334
+ attempt = 0,
286
335
  ): Promise<boolean> {
287
336
  const _hasToken = deps.hasToken ?? hasToken;
288
337
  const _request = deps.requestAccessToken ?? requestAccessToken;
@@ -297,6 +346,7 @@ export async function setupTaskGithub(
297
346
  if (tok) {
298
347
  await _inject(taskId, tok.accessToken);
299
348
  _schedule(taskId, userId, tok.expiresAt);
349
+ clearGithubRetry(taskId);
300
350
  console.log(`[github] task ${taskId}: injected user access token`);
301
351
  return true;
302
352
  }
@@ -304,6 +354,7 @@ export async function setupTaskGithub(
304
354
  const pat = process.env.UAI_GH_PAT_FALLBACK;
305
355
  if (pat) {
306
356
  await _inject(taskId, pat);
357
+ clearGithubRetry(taskId);
307
358
  console.log(`[github] task ${taskId}: using PAT fallback (no refresh)`);
308
359
  return true;
309
360
  }
@@ -313,14 +364,19 @@ export async function setupTaskGithub(
313
364
  const reason = err instanceof Error ? err.message : String(err);
314
365
  console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
315
366
  authExpiredHandler?.(taskId, userId, reason);
367
+ // Self-heal: a transient exchange/inject failure shouldn't strand the task
368
+ // on a stale token until a manual /retry-gh. Retry with bounded backoff.
369
+ scheduleGithubRetry(taskId, userId, attempt);
316
370
  return false;
317
371
  } finally {
318
372
  setupInFlight.delete(taskId);
319
373
  }
320
374
  }
321
375
 
322
- /** Test/maintenance hook: drop all scheduled refresh timers. */
376
+ /** Test/maintenance hook: drop all scheduled refresh + retry timers. */
323
377
  export function clearAllRefreshTimers(): void {
324
378
  for (const t of timers.values()) clearTimeout(t);
325
379
  timers.clear();
380
+ for (const t of retryTimers.values()) clearTimeout(t);
381
+ retryTimers.clear();
326
382
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",