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

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
@@ -357,6 +364,9 @@ export class FleetManager {
357
364
  return null;
358
365
  return this.instanceStateCache.get(name)?.state ?? null;
359
366
  }
367
+ isClassicInstance(name) {
368
+ return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
369
+ }
360
370
  cacheInstanceExecutionState(name, msg) {
361
371
  const state = msg.state;
362
372
  if (state !== "idle" && state !== "working" && state !== "stuck")
@@ -370,16 +380,100 @@ export class FleetManager {
370
380
  observedAt: numberOr(msg.observedAt, now),
371
381
  stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
372
382
  });
383
+ for (const check of this.instanceIdleWaiters.get(name) ?? [])
384
+ check();
385
+ }
386
+ waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0) {
387
+ const isReady = () => {
388
+ const snapshot = this.instanceStateCache.get(instanceName);
389
+ return snapshot?.state === "idle"
390
+ && (idleObservedAfter === 0 || snapshot.observedAt > idleObservedAfter);
391
+ };
392
+ if (isReady())
393
+ return Promise.resolve(true);
394
+ return new Promise(resolve => {
395
+ let settled = false;
396
+ const finish = (idle) => {
397
+ if (settled)
398
+ return;
399
+ settled = true;
400
+ clearTimeout(timeout);
401
+ clearInterval(queryTimer);
402
+ const waiters = this.instanceIdleWaiters.get(instanceName);
403
+ waiters?.delete(check);
404
+ if (waiters?.size === 0)
405
+ this.instanceIdleWaiters.delete(instanceName);
406
+ resolve(idle);
407
+ };
408
+ const check = () => { if (isReady())
409
+ finish(true); };
410
+ const query = () => {
411
+ const ipc = this.instanceIpcClients.get(instanceName);
412
+ if (ipc?.connected) {
413
+ ipc.send({ type: "query_instance_state", requestId: `idle-gate-${Date.now()}` });
414
+ }
415
+ check();
416
+ };
417
+ const waiters = this.instanceIdleWaiters.get(instanceName) ?? new Set();
418
+ waiters.add(check);
419
+ this.instanceIdleWaiters.set(instanceName, waiters);
420
+ const timeout = setTimeout(() => finish(false), timeoutMs);
421
+ const queryTimer = setInterval(query, 1_000);
422
+ query();
423
+ });
373
424
  }
374
- /** Single delivery facade: wake an auto-paused CLI before sending to daemon IPC. */
375
- async deliverToInstance(instanceName, payload) {
425
+ async deliverWithIdleGate(instanceName, payload, timeoutMs) {
426
+ let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
376
427
  if (this.lifecycle.isPaused(instanceName)) {
428
+ const wakeStartedAt = Date.now();
377
429
  await this.lifecycle.wake(instanceName, 30_000);
430
+ // Never satisfy a post-wake gate from a stale pre-pause cache entry.
431
+ idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
432
+ }
433
+ const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter);
434
+ if (!idle) {
435
+ this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
378
436
  }
379
437
  const ipc = this.instanceIpcClients.get(instanceName);
380
438
  if (!ipc?.connected)
381
439
  throw new Error(`Instance '${instanceName}' IPC is unavailable`);
382
440
  ipc.send(payload);
441
+ this.lastDeliveryAt.set(instanceName, Date.now());
442
+ }
443
+ /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
444
+ async deliverToInstance(instanceName, payload, options = {}) {
445
+ const meta = payload.meta && typeof payload.meta === "object"
446
+ ? payload.meta
447
+ : undefined;
448
+ const inferredCrossInstance = (typeof meta?.from_instance === "string" && meta.from_instance.length > 0)
449
+ || meta?.is_cross_instance === true
450
+ || payload.is_cross_instance === true;
451
+ const waitForIdle = options.waitForIdle
452
+ ?? ((options.isCrossInstance ?? inferredCrossInstance) || payload.type === "fleet_schedule_trigger");
453
+ if (!waitForIdle) {
454
+ if (this.lifecycle.isPaused(instanceName)) {
455
+ await this.lifecycle.wake(instanceName, 30_000);
456
+ }
457
+ const ipc = this.instanceIpcClients.get(instanceName);
458
+ if (!ipc?.connected)
459
+ throw new Error(`Instance '${instanceName}' IPC is unavailable`);
460
+ ipc.send(payload);
461
+ // A cross-instance item arriving before the daemon observes this turn as
462
+ // working must not trust the stale idle snapshot from before the send.
463
+ this.lastDeliveryAt.set(instanceName, Date.now());
464
+ return;
465
+ }
466
+ const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
467
+ const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, payload, options.idleTimeoutMs ?? 60_000));
468
+ this.idleGatedDeliveryTails.set(instanceName, delivery);
469
+ try {
470
+ await delivery;
471
+ }
472
+ finally {
473
+ if (this.idleGatedDeliveryTails.get(instanceName) === delivery) {
474
+ this.idleGatedDeliveryTails.delete(instanceName);
475
+ }
476
+ }
383
477
  }
384
478
  /** Fleet admin is an explicit config allowlist entry, not merely an open/paired user. */
385
479
  isFleetAdmin(userId, adapterId) {
@@ -408,9 +502,13 @@ export class FleetManager {
408
502
  return;
409
503
  await this.stopInstance(instanceName);
410
504
  await new Promise(resolve => setTimeout(resolve, 250));
411
- 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));
505
+ 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), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
412
506
  }
413
507
  async startInstance(name, config, topicMode) {
508
+ if (this.lifecycle.isPaused(name)) {
509
+ this.logger.info({ name }, "Persisted paused instance — skipping startup");
510
+ return;
511
+ }
414
512
  if (config.general_topic) {
415
513
  this.ensureGeneralInstructions(config.working_directory, config.backend);
416
514
  }
@@ -418,6 +516,20 @@ export class FleetManager {
418
516
  // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
419
517
  await this.connectIpcToInstance(name);
420
518
  }
519
+ /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
520
+ async startPersistedPausedInstance(name) {
521
+ const topicMode = this.fleetConfig?.channel?.mode === "topic"
522
+ || !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
523
+ const fleetConfig = this.fleetConfig?.instances[name];
524
+ if (fleetConfig) {
525
+ await this.startInstance(name, fleetConfig, topicMode);
526
+ return;
527
+ }
528
+ const channel = this.classicChannels?.getAll().find(item => item.instanceName === name);
529
+ if (!channel || !this.classicChannels)
530
+ throw new Error(`Paused instance '${name}' is no longer configured`);
531
+ 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), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
532
+ }
421
533
  /**
422
534
  * Start instances with configurable concurrency and stagger delay.
423
535
  * Instances sharing the same working_directory are serialized within a group
@@ -478,6 +590,7 @@ export class FleetManager {
478
590
  async stopInstance(name) {
479
591
  this.failoverActive.delete(name);
480
592
  this.instanceStateCache.delete(name);
593
+ this.lastDeliveryAt.delete(name);
481
594
  return this.lifecycle.stop(name);
482
595
  }
483
596
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
@@ -588,9 +701,11 @@ export class FleetManager {
588
701
  const fleetModel = this.fleetConfig?.defaults?.model;
589
702
  const oldBackends = new Map();
590
703
  const oldModels = new Map();
704
+ const oldAutoPause = new Map();
591
705
  for (const ch of this.classicChannels.getAll()) {
592
706
  oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend));
593
707
  oldModels.set(ch.instanceName, this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel));
708
+ oldAutoPause.set(ch.instanceName, this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
594
709
  }
595
710
  if (!this.classicChannels.checkReload())
596
711
  return;
@@ -598,14 +713,16 @@ export class FleetManager {
598
713
  for (const ch of this.classicChannels.getAll()) {
599
714
  const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
600
715
  const newModel = this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel);
716
+ const newAutoPause = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
601
717
  const backendChanged = oldBackends.get(ch.instanceName) !== newBackend;
602
718
  const modelChanged = oldModels.get(ch.instanceName) !== newModel;
603
- if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged)) {
719
+ const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
720
+ if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged)) {
604
721
  this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel }, "Backend/model changed — restarting");
605
722
  await this.stopInstance(ch.instanceName).catch(() => { });
606
723
  // Small delay to let tmux window clean up
607
724
  await new Promise(r => setTimeout(r, 2000));
608
- await this.startClassicInstance(ch.instanceName, newBackend, this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), newModel).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to restart classic instance"));
725
+ await this.startClassicInstance(ch.instanceName, newBackend, this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), newModel, newAutoPause).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to restart classic instance"));
609
726
  }
610
727
  }
611
728
  }
@@ -858,7 +975,7 @@ export class FleetManager {
858
975
  let idx = 0;
859
976
  while (idx < channels.length) {
860
977
  const batch = channels.slice(idx, idx + concurrency);
861
- await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
978
+ await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
862
979
  idx += concurrency;
863
980
  }
864
981
  }
@@ -2375,7 +2492,7 @@ export class FleetManager {
2375
2492
  type: "fleet_schedule_trigger",
2376
2493
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
2377
2494
  meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
2378
- });
2495
+ }, { waitForIdle: true });
2379
2496
  // A scheduled trigger also puts the instance to work — show a cancel button.
2380
2497
  void this.sendCancelButton(target);
2381
2498
  return true;
@@ -2943,6 +3060,8 @@ export class FleetManager {
2943
3060
  }
2944
3061
  }
2945
3062
  startStatuslineWatcher(name) {
3063
+ if (this.lifecycle.isPaused(name))
3064
+ return;
2946
3065
  this.statuslineWatcher.watch(name);
2947
3066
  }
2948
3067
  stopStatuslineWatcher(name) {
@@ -4019,7 +4138,7 @@ When users create specialized instances, suggest these configurations:
4019
4138
  await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
4020
4139
  }
4021
4140
  /** Start a classic channel instance with lightweight config */
4022
- async startClassicInstance(instanceName, backend, preTaskCommand, model) {
4141
+ async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter) {
4023
4142
  if (this.daemons.has(instanceName))
4024
4143
  return;
4025
4144
  const workDir = join(getAgendHome(), "workspaces", instanceName);
@@ -4031,6 +4150,7 @@ When users create specialized instances, suggest these configurations:
4031
4150
  lightweight: true,
4032
4151
  ...(backend ? { backend } : {}),
4033
4152
  ...(model ? { model } : {}),
4153
+ ...(autoPauseAfter !== undefined ? { auto_pause_after: autoPauseAfter } : {}),
4034
4154
  ...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
4035
4155
  };
4036
4156
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
@@ -4045,6 +4165,7 @@ When users create specialized instances, suggest these configurations:
4045
4165
  if (!classicChannels)
4046
4166
  return "Classic channel manager not initialized.";
4047
4167
  const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
4168
+ clearPausedMarker(this.getInstanceDir(instanceName));
4048
4169
  const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
4049
4170
  classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId, selectedBackend);
4050
4171
  // Bind this classic instance to the bot that started it (authoritative), so
@@ -4052,7 +4173,7 @@ When users create specialized instances, suggest these configurations:
4052
4173
  // also sees the channel's messages.
4053
4174
  if (adapterId)
4054
4175
  this.bindInstanceAdapter(instanceName, adapterId);
4055
- await this.startClassicInstance(instanceName, classicChannels.getBackend(channelId, adapterId, this.fleetConfig?.defaults?.backend), classicChannels.getPreTaskCommand(channelId, adapterId), classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model));
4176
+ await this.startClassicInstance(instanceName, classicChannels.getBackend(channelId, adapterId, this.fleetConfig?.defaults?.backend), classicChannels.getPreTaskCommand(channelId, adapterId), classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
4056
4177
  this.reregisterClassicChannels();
4057
4178
  // Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
4058
4179
  if (guildId && !classicChannels.isCollab(channelId, adapterId)) {
@@ -4070,6 +4191,7 @@ When users create specialized instances, suggest these configurations:
4070
4191
  return t("classic.no_agent");
4071
4192
  this.instanceWorldBinding.delete(ch.instanceName);
4072
4193
  await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
4194
+ clearPausedMarker(this.getInstanceDir(ch.instanceName));
4073
4195
  this.reregisterClassicChannels();
4074
4196
  this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
4075
4197
  return t("classic.stopped");
@@ -4410,7 +4532,7 @@ When users create specialized instances, suggest these configurations:
4410
4532
  let idx = 0;
4411
4533
  while (idx < channels.length) {
4412
4534
  const batch = channels.slice(idx, idx + concurrency);
4413
- await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
4535
+ await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
4414
4536
  idx += concurrency;
4415
4537
  }
4416
4538
  }