@songsid/agend 2.1.5-beta.13 → 2.1.5-beta.14

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.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Run work under a wall-clock deadline without cancelling it.
3
+ *
4
+ * Nothing here aborts anything — a tmux spawn or an HTTP call keeps going after
5
+ * its deadline passes; the caller simply stops waiting and can say so. That is
6
+ * the point: a step with no deadline cannot report anything while it hangs, so
7
+ * the user sees silence and assumes the whole operation is stuck (#722, and the
8
+ * post-login recovery that silently swallowed its own success message).
9
+ *
10
+ * Rejections are reported rather than thrown, so one failed step never takes
11
+ * the surrounding loop down with it, and are always observed — an abandoned
12
+ * promise that rejects later must not surface as an unhandled rejection.
13
+ */
14
+ export type DeadlineResult<T> = {
15
+ status: "fulfilled";
16
+ value: T;
17
+ } | {
18
+ status: "rejected";
19
+ reason: unknown;
20
+ } | {
21
+ status: "timeout";
22
+ };
23
+ export declare function runBeforeDeadline<T>(start: () => Promise<T>, deadline: number): Promise<DeadlineResult<T>>;
@@ -0,0 +1,29 @@
1
+ /** Largest delay setTimeout honours; anything above is clamped to 1ms by Node. */
2
+ const MAX_TIMER_MS = 2_147_483_647;
3
+ export async function runBeforeDeadline(start, deadline) {
4
+ const remaining = deadline - Date.now();
5
+ if (remaining <= 0)
6
+ return { status: "timeout" };
7
+ let work;
8
+ try {
9
+ work = start();
10
+ }
11
+ catch (reason) {
12
+ // A synchronous throw from `start` is a rejection like any other.
13
+ return { status: "rejected", reason };
14
+ }
15
+ let timer;
16
+ const timeout = new Promise(resolve => {
17
+ // Node clamps a delay above the 32-bit maximum to 1ms — so a very distant
18
+ // deadline would fire IMMEDIATELY and report every step as timed out, the
19
+ // exact opposite of what the caller asked for. Cap instead.
20
+ timer = setTimeout(() => resolve({ status: "timeout" }), Math.min(remaining, MAX_TIMER_MS));
21
+ timer.unref?.();
22
+ });
23
+ const settled = work.then(value => ({ status: "fulfilled", value }), reason => ({ status: "rejected", reason }));
24
+ const result = await Promise.race([settled, timeout]);
25
+ if (timer)
26
+ clearTimeout(timer);
27
+ return result;
28
+ }
29
+ //# sourceMappingURL=deadline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deadline.js","sourceRoot":"","sources":["../src/deadline.ts"],"names":[],"mappings":"AAkBA,kFAAkF;AAClF,MAAM,YAAY,GAAG,aAAa,CAAC;AAEnC,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAI,KAAuB,EAAE,QAAgB;IAClF,MAAM,SAAS,GAAG,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACxC,IAAI,SAAS,IAAI,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IACjD,IAAI,IAAgB,CAAC;IACrB,IAAI,CAAC;QACH,IAAI,GAAG,KAAK,EAAE,CAAC;IACjB,CAAC;IAAC,OAAO,MAAM,EAAE,CAAC;QAChB,kEAAkE;QAClE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC;IACxC,CAAC;IACD,IAAI,KAAgD,CAAC;IACrD,MAAM,OAAO,GAAG,IAAI,OAAO,CAAoB,OAAO,CAAC,EAAE;QACvD,0EAA0E;QAC1E,0EAA0E;QAC1E,4DAA4D;QAC5D,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;QAC5F,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;IAClB,CAAC,CAAC,CAAC;IACH,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CACvB,KAAK,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC,EACzC,MAAM,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC,CAC3C,CAAC;IACF,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,IAAI,KAAK;QAAE,YAAY,CAAC,KAAK,CAAC,CAAC;IAC/B,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -66,6 +66,7 @@ export interface DeliveryOptions {
66
66
  /** Test/operational override; normal deliveries use the 60 second backstop. */
67
67
  idleTimeoutMs?: number;
68
68
  }
69
+ export declare const LOGIN_CALLBACK_PREFIX = "login:";
69
70
  /**
70
71
  * Upper bound on a live probe driven by `/model`.
71
72
  *
@@ -928,6 +929,17 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
928
929
  * contain usernames or secret-adjacent text and stays in daemon.log.
929
930
  */
930
931
  notifyInteractivePrompt(instanceName: string, kind: string): Promise<void>;
932
+ /**
933
+ * Offer a one-tap re-login next to an auth alert.
934
+ *
935
+ * The alert already names the remedy in words (`/login <backend>`), which
936
+ * still leaves the user to retype it somewhere. The button routes into the
937
+ * same chooser `/login` uses, so pressing it starts the flow in place.
938
+ *
939
+ * Backends with no remote login flow (opencode logs in from a terminal) get
940
+ * no button — the alert's own wording already tells them what to run.
941
+ */
942
+ offerBackendLogin(targetInstance: string, backend: string): Promise<void>;
931
943
  /** Consume a General assist button exactly once. */
932
944
  private handleInteractivePromptAssist;
933
945
  /** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
@@ -1188,6 +1200,17 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1188
1200
  * only re-reads credentials on process start (a paused instance's CLI is
1189
1201
  * already dead, so waking it respawns with the new token for free).
1190
1202
  */
1203
+ /**
1204
+ * Wake/restart every instance of a backend after a successful re-login.
1205
+ *
1206
+ * Bounded by a wall-clock deadline. This used to be an unbounded sequential
1207
+ * loop, and the caller only built its "login completed" message AFTER it
1208
+ * returned — so with several instances (or one slow restart) the user was
1209
+ * told nothing at all for minutes, concluded the login had hung, and
1210
+ * restarted things by hand. Instances that do not finish in time are NOT
1211
+ * cancelled: they are still coming back, and are reported as pending so the
1212
+ * message can say so instead of implying failure.
1213
+ */
1191
1214
  private recoverBackendInstances;
1192
1215
  /** Backend chooser button → start that backend's login session. */
1193
1216
  private handleLoginBackendSelect;
@@ -48,7 +48,8 @@ import { handleViewRequest, isViewPath } from "./view-api.js";
48
48
  import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
49
49
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
50
50
  import { LoginSession } from "./login-manager.js";
51
- import { LoginController, LOGIN_TOKEN_RESEND_PREFIX } from "./login-controller.js";
51
+ import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS, announcePostLoginRecovery } from "./login-controller.js";
52
+ import { runBeforeDeadline } from "./deadline.js";
52
53
  import { LoginWindowLock } from "./login-window-lock.js";
53
54
  import { handleSettingsRequest } from "./settings-api.js";
54
55
  import { setLocale, detectLocale, getLocale, t } from "./locale.js";
@@ -219,7 +220,7 @@ const HANG_CALLBACK_PREFIX = "hang:";
219
220
  const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
220
221
  const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
221
222
  const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
222
- const LOGIN_CALLBACK_PREFIX = "login:";
223
+ export const LOGIN_CALLBACK_PREFIX = "login:";
223
224
  const INSTALL_CALLBACK_PREFIX = "install-select:";
224
225
  const CLASSIC_APPROVE_CALLBACK_PREFIX = "classic-approve:";
225
226
  const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
@@ -6555,6 +6556,41 @@ export class FleetManager {
6555
6556
  extra: { generalName, promptKind: kind },
6556
6557
  });
6557
6558
  }
6559
+ /**
6560
+ * Offer a one-tap re-login next to an auth alert.
6561
+ *
6562
+ * The alert already names the remedy in words (`/login <backend>`), which
6563
+ * still leaves the user to retype it somewhere. The button routes into the
6564
+ * same chooser `/login` uses, so pressing it starts the flow in place.
6565
+ *
6566
+ * Backends with no remote login flow (opencode logs in from a terminal) get
6567
+ * no button — the alert's own wording already tells them what to run.
6568
+ */
6569
+ async offerBackendLogin(targetInstance, backend) {
6570
+ if (!LOGIN_FLOWS[backend])
6571
+ return;
6572
+ const adapterId = this.getInstanceAdapterId(targetInstance);
6573
+ const adapter = this.getAdapterForInstance(targetInstance);
6574
+ const chatId = this.getGroupIdForInstance(targetInstance);
6575
+ const topicId = this.fleetConfig?.instances[targetInstance]?.topic_id;
6576
+ const threadId = topicId != null ? String(topicId) : undefined;
6577
+ if (!adapter || !adapterId || !chatId) {
6578
+ this.logger.warn({ targetInstance, backend, adapterId, chatId }, "Cannot address the re-login button — the alert text still names the command");
6579
+ return;
6580
+ }
6581
+ await this.postNonceButtonPrompt({
6582
+ prefix: LOGIN_CALLBACK_PREFIX,
6583
+ alertType: "login",
6584
+ instanceName: "login",
6585
+ adapter,
6586
+ adapterId,
6587
+ chatId,
6588
+ threadId,
6589
+ message: t("login.offer", backend),
6590
+ choices: [{ action: backend, label: t("login.offer_action", backend) }],
6591
+ expiredText: t("buttons.stale"),
6592
+ });
6593
+ }
6558
6594
  /** Consume a General assist button exactly once. */
6559
6595
  async handleInteractivePromptAssist(data, callbackAdapterId, receivingAdapter) {
6560
6596
  const claimed = this.consumeNonceCallback(INTERACTIVE_ASSIST_CALLBACK_PREFIX, /^interactive-assist:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
@@ -7543,11 +7579,15 @@ export class FleetManager {
7543
7579
  if (cleanupFailed) {
7544
7580
  await chat.adapter.sendText(chat.chatId, t("login.web_cleanup_failed", backend), { threadId: chat.threadId }).catch(() => { });
7545
7581
  }
7582
+ const send = (text) => chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId }).catch(() => { });
7546
7583
  let text;
7547
7584
  if (ok) {
7548
- const { woken, restarted } = await this.recoverBackendInstances(backend);
7549
- const none = t("login.none");
7550
- text = t("login.success", backend, woken.length ? woken.join(", ") : none, restarted.length ? restarted.join(", ") : none);
7585
+ // Same order as the web-login path: the login result goes out first,
7586
+ // then the recovery reports its own outcome. Waiting for recovery to
7587
+ // build this message is what made a successful login look hung.
7588
+ await send(t("login.completed", backend));
7589
+ await announcePostLoginRecovery(backend, () => this.recoverBackendInstances(backend), send);
7590
+ return;
7551
7591
  }
7552
7592
  else if (detail === "cancelled") {
7553
7593
  // The cancel command's own reply already announced this — a second
@@ -7671,31 +7711,53 @@ export class FleetManager {
7671
7711
  * only re-reads credentials on process start (a paused instance's CLI is
7672
7712
  * already dead, so waking it respawns with the new token for free).
7673
7713
  */
7674
- async recoverBackendInstances(backend) {
7714
+ /**
7715
+ * Wake/restart every instance of a backend after a successful re-login.
7716
+ *
7717
+ * Bounded by a wall-clock deadline. This used to be an unbounded sequential
7718
+ * loop, and the caller only built its "login completed" message AFTER it
7719
+ * returned — so with several instances (or one slow restart) the user was
7720
+ * told nothing at all for minutes, concluded the login had hung, and
7721
+ * restarted things by hand. Instances that do not finish in time are NOT
7722
+ * cancelled: they are still coming back, and are reported as pending so the
7723
+ * message can say so instead of implying failure.
7724
+ */
7725
+ async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS) {
7675
7726
  const woken = [];
7676
7727
  const restarted = [];
7728
+ const pending = [];
7729
+ const deadline = Date.now() + deadlineMs;
7677
7730
  for (const name of this.configuredBackendInstanceNames()) {
7678
7731
  if (this.backendNameOf(name) !== backend)
7679
7732
  continue;
7680
7733
  const status = this.getInstanceStatus(name);
7681
- try {
7734
+ if (status !== "paused" && status !== "running")
7735
+ continue;
7736
+ const result = await runBeforeDeadline(async () => {
7682
7737
  if (status === "paused") {
7683
7738
  if (this.daemons.has(name))
7684
7739
  await this.lifecycle.wake(name, 30_000);
7685
7740
  else
7686
7741
  await this.startPersistedPausedInstance(name);
7687
- woken.push(name);
7688
7742
  }
7689
- else if (status === "running") {
7743
+ else {
7690
7744
  await this.restartSingleInstance(name);
7691
- restarted.push(name);
7692
7745
  }
7746
+ }, deadline);
7747
+ if (result.status === "fulfilled") {
7748
+ (status === "paused" ? woken : restarted).push(name);
7693
7749
  }
7694
- catch (err) {
7695
- this.logger.warn({ err: err.message, name, status }, "Post-login recovery failed");
7750
+ else if (result.status === "timeout") {
7751
+ // Out of time: record it and stop waiting, but keep walking the list —
7752
+ // the remaining instances are checked against the same deadline and
7753
+ // fall straight through, so the caller still learns about all of them.
7754
+ pending.push(name);
7755
+ }
7756
+ else {
7757
+ this.logger.warn({ err: result.reason?.message, name, status }, "Post-login recovery failed");
7696
7758
  }
7697
7759
  }
7698
- return { woken, restarted };
7760
+ return { woken, restarted, pending };
7699
7761
  }
7700
7762
  /** Backend chooser button → start that backend's login session. */
7701
7763
  async handleLoginBackendSelect(data, callbackAdapterId, receivingAdapter) {