@songsid/agend 2.1.0-beta.27 → 2.1.0-beta.28

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.
@@ -41,6 +41,7 @@ import { setLocale, detectLocale, t } from "./locale.js";
41
41
  import { handleAgentRequest } from "./agent-endpoint.js";
42
42
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
43
43
  import { readLastInboundAt } from "./daemon.js";
44
+ import { clearPausedMarker } from "./pause-marker.js";
44
45
  import { getTmuxSession } from "./config.js";
45
46
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
46
47
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -101,6 +102,12 @@ export class FleetManager {
101
102
  lastActivity = new Map();
102
103
  /** Latest pane-derived execution snapshot reported by each daemon. */
103
104
  instanceStateCache = new Map();
105
+ /** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
106
+ idleGatedDeliveryTails = new Map();
107
+ /** Non-user work must observe a fresh idle snapshot after the latest delivery. */
108
+ lastDeliveryAt = new Map();
109
+ /** State-cache updates wake event-driven idle waiters without busy polling. */
110
+ instanceIdleWaiters = new Map();
104
111
  lastInboundUser = new Map(); // instanceName → last username
105
112
  // Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
106
113
  // than one-per-instance. A button is retired (deleted, with bounded retry) on
@@ -370,16 +377,100 @@ export class FleetManager {
370
377
  observedAt: numberOr(msg.observedAt, now),
371
378
  stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
372
379
  });
380
+ for (const check of this.instanceIdleWaiters.get(name) ?? [])
381
+ check();
382
+ }
383
+ waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0) {
384
+ const isReady = () => {
385
+ const snapshot = this.instanceStateCache.get(instanceName);
386
+ return snapshot?.state === "idle"
387
+ && (idleObservedAfter === 0 || snapshot.observedAt > idleObservedAfter);
388
+ };
389
+ if (isReady())
390
+ return Promise.resolve(true);
391
+ return new Promise(resolve => {
392
+ let settled = false;
393
+ const finish = (idle) => {
394
+ if (settled)
395
+ return;
396
+ settled = true;
397
+ clearTimeout(timeout);
398
+ clearInterval(queryTimer);
399
+ const waiters = this.instanceIdleWaiters.get(instanceName);
400
+ waiters?.delete(check);
401
+ if (waiters?.size === 0)
402
+ this.instanceIdleWaiters.delete(instanceName);
403
+ resolve(idle);
404
+ };
405
+ const check = () => { if (isReady())
406
+ finish(true); };
407
+ const query = () => {
408
+ const ipc = this.instanceIpcClients.get(instanceName);
409
+ if (ipc?.connected) {
410
+ ipc.send({ type: "query_instance_state", requestId: `idle-gate-${Date.now()}` });
411
+ }
412
+ check();
413
+ };
414
+ const waiters = this.instanceIdleWaiters.get(instanceName) ?? new Set();
415
+ waiters.add(check);
416
+ this.instanceIdleWaiters.set(instanceName, waiters);
417
+ const timeout = setTimeout(() => finish(false), timeoutMs);
418
+ const queryTimer = setInterval(query, 1_000);
419
+ query();
420
+ });
373
421
  }
374
- /** Single delivery facade: wake an auto-paused CLI before sending to daemon IPC. */
375
- async deliverToInstance(instanceName, payload) {
422
+ async deliverWithIdleGate(instanceName, payload, timeoutMs) {
423
+ let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
376
424
  if (this.lifecycle.isPaused(instanceName)) {
425
+ const wakeStartedAt = Date.now();
377
426
  await this.lifecycle.wake(instanceName, 30_000);
427
+ // Never satisfy a post-wake gate from a stale pre-pause cache entry.
428
+ idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
429
+ }
430
+ const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter);
431
+ if (!idle) {
432
+ this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
378
433
  }
379
434
  const ipc = this.instanceIpcClients.get(instanceName);
380
435
  if (!ipc?.connected)
381
436
  throw new Error(`Instance '${instanceName}' IPC is unavailable`);
382
437
  ipc.send(payload);
438
+ this.lastDeliveryAt.set(instanceName, Date.now());
439
+ }
440
+ /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
441
+ async deliverToInstance(instanceName, payload, options = {}) {
442
+ const meta = payload.meta && typeof payload.meta === "object"
443
+ ? payload.meta
444
+ : undefined;
445
+ const inferredCrossInstance = (typeof meta?.from_instance === "string" && meta.from_instance.length > 0)
446
+ || meta?.is_cross_instance === true
447
+ || payload.is_cross_instance === true;
448
+ const waitForIdle = options.waitForIdle
449
+ ?? ((options.isCrossInstance ?? inferredCrossInstance) || payload.type === "fleet_schedule_trigger");
450
+ if (!waitForIdle) {
451
+ if (this.lifecycle.isPaused(instanceName)) {
452
+ await this.lifecycle.wake(instanceName, 30_000);
453
+ }
454
+ const ipc = this.instanceIpcClients.get(instanceName);
455
+ if (!ipc?.connected)
456
+ throw new Error(`Instance '${instanceName}' IPC is unavailable`);
457
+ ipc.send(payload);
458
+ // A cross-instance item arriving before the daemon observes this turn as
459
+ // working must not trust the stale idle snapshot from before the send.
460
+ this.lastDeliveryAt.set(instanceName, Date.now());
461
+ return;
462
+ }
463
+ const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
464
+ const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, payload, options.idleTimeoutMs ?? 60_000));
465
+ this.idleGatedDeliveryTails.set(instanceName, delivery);
466
+ try {
467
+ await delivery;
468
+ }
469
+ finally {
470
+ if (this.idleGatedDeliveryTails.get(instanceName) === delivery) {
471
+ this.idleGatedDeliveryTails.delete(instanceName);
472
+ }
473
+ }
383
474
  }
384
475
  /** Fleet admin is an explicit config allowlist entry, not merely an open/paired user. */
385
476
  isFleetAdmin(userId, adapterId) {
@@ -411,6 +502,10 @@ export class FleetManager {
411
502
  await this.startClassicInstance(instanceName, this.classicChannels.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model));
412
503
  }
413
504
  async startInstance(name, config, topicMode) {
505
+ if (this.lifecycle.isPaused(name)) {
506
+ this.logger.info({ name }, "Persisted paused instance — skipping startup");
507
+ return;
508
+ }
414
509
  if (config.general_topic) {
415
510
  this.ensureGeneralInstructions(config.working_directory, config.backend);
416
511
  }
@@ -418,6 +513,20 @@ export class FleetManager {
418
513
  // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
419
514
  await this.connectIpcToInstance(name);
420
515
  }
516
+ /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
517
+ async startPersistedPausedInstance(name) {
518
+ const topicMode = this.fleetConfig?.channel?.mode === "topic"
519
+ || !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
520
+ const fleetConfig = this.fleetConfig?.instances[name];
521
+ if (fleetConfig) {
522
+ await this.startInstance(name, fleetConfig, topicMode);
523
+ return;
524
+ }
525
+ const channel = this.classicChannels?.getAll().find(item => item.instanceName === name);
526
+ if (!channel || !this.classicChannels)
527
+ throw new Error(`Paused instance '${name}' is no longer configured`);
528
+ await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model));
529
+ }
421
530
  /**
422
531
  * Start instances with configurable concurrency and stagger delay.
423
532
  * Instances sharing the same working_directory are serialized within a group
@@ -478,6 +587,7 @@ export class FleetManager {
478
587
  async stopInstance(name) {
479
588
  this.failoverActive.delete(name);
480
589
  this.instanceStateCache.delete(name);
590
+ this.lastDeliveryAt.delete(name);
481
591
  return this.lifecycle.stop(name);
482
592
  }
483
593
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
@@ -2375,7 +2485,7 @@ export class FleetManager {
2375
2485
  type: "fleet_schedule_trigger",
2376
2486
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
2377
2487
  meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
2378
- });
2488
+ }, { waitForIdle: true });
2379
2489
  // A scheduled trigger also puts the instance to work — show a cancel button.
2380
2490
  void this.sendCancelButton(target);
2381
2491
  return true;
@@ -2943,6 +3053,8 @@ export class FleetManager {
2943
3053
  }
2944
3054
  }
2945
3055
  startStatuslineWatcher(name) {
3056
+ if (this.lifecycle.isPaused(name))
3057
+ return;
2946
3058
  this.statuslineWatcher.watch(name);
2947
3059
  }
2948
3060
  stopStatuslineWatcher(name) {
@@ -4045,6 +4157,7 @@ When users create specialized instances, suggest these configurations:
4045
4157
  if (!classicChannels)
4046
4158
  return "Classic channel manager not initialized.";
4047
4159
  const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
4160
+ clearPausedMarker(this.getInstanceDir(instanceName));
4048
4161
  const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
4049
4162
  classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId, selectedBackend);
4050
4163
  // Bind this classic instance to the bot that started it (authoritative), so
@@ -4070,6 +4183,7 @@ When users create specialized instances, suggest these configurations:
4070
4183
  return t("classic.no_agent");
4071
4184
  this.instanceWorldBinding.delete(ch.instanceName);
4072
4185
  await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
4186
+ clearPausedMarker(this.getInstanceDir(ch.instanceName));
4073
4187
  this.reregisterClassicChannels();
4074
4188
  this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
4075
4189
  return t("classic.stopped");