@songsid/agend 2.1.0-beta.26 → 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.
@@ -39,8 +39,9 @@ import { handleViewRequest, isViewPath } from "./view-api.js";
39
39
  import { handleSettingsRequest } from "./settings-api.js";
40
40
  import { setLocale, detectLocale, t } from "./locale.js";
41
41
  import { handleAgentRequest } from "./agent-endpoint.js";
42
- import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend } from "./classic-channel-manager.js";
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) {
@@ -394,7 +485,27 @@ export class FleetManager {
394
485
  await this.lifecycle.pause(name);
395
486
  return this.lifecycle.isPaused(name) ? "paused" : "not_idle";
396
487
  }
488
+ /** Apply a Settings edit to a ClassicBot channel without waiting for the poller. */
489
+ async restartClassicInstanceFromSettings(instanceName) {
490
+ if (!this.classicChannels)
491
+ throw new Error("Classic channel manager not initialized");
492
+ const wasRunning = this.daemons.has(instanceName);
493
+ this.classicChannels.reloadFromDisk();
494
+ this.reregisterClassicChannels();
495
+ const channel = this.classicChannels.getAll().find(item => item.instanceName === instanceName);
496
+ if (!channel)
497
+ throw new Error("Classic channel not found after reload");
498
+ if (!wasRunning)
499
+ return;
500
+ await this.stopInstance(instanceName);
501
+ await new Promise(resolve => setTimeout(resolve, 250));
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));
503
+ }
397
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
+ }
398
509
  if (config.general_topic) {
399
510
  this.ensureGeneralInstructions(config.working_directory, config.backend);
400
511
  }
@@ -402,6 +513,20 @@ export class FleetManager {
402
513
  // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
403
514
  await this.connectIpcToInstance(name);
404
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
+ }
405
530
  /**
406
531
  * Start instances with configurable concurrency and stagger delay.
407
532
  * Instances sharing the same working_directory are serialized within a group
@@ -462,6 +587,7 @@ export class FleetManager {
462
587
  async stopInstance(name) {
463
588
  this.failoverActive.delete(name);
464
589
  this.instanceStateCache.delete(name);
590
+ this.lastDeliveryAt.delete(name);
465
591
  return this.lifecycle.stop(name);
466
592
  }
467
593
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
@@ -2359,7 +2485,7 @@ export class FleetManager {
2359
2485
  type: "fleet_schedule_trigger",
2360
2486
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
2361
2487
  meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
2362
- });
2488
+ }, { waitForIdle: true });
2363
2489
  // A scheduled trigger also puts the instance to work — show a cancel button.
2364
2490
  void this.sendCancelButton(target);
2365
2491
  return true;
@@ -2927,6 +3053,8 @@ export class FleetManager {
2927
3053
  }
2928
3054
  }
2929
3055
  startStatuslineWatcher(name) {
3056
+ if (this.lifecycle.isPaused(name))
3057
+ return;
2930
3058
  this.statuslineWatcher.watch(name);
2931
3059
  }
2932
3060
  stopStatuslineWatcher(name) {
@@ -4029,6 +4157,7 @@ When users create specialized instances, suggest these configurations:
4029
4157
  if (!classicChannels)
4030
4158
  return "Classic channel manager not initialized.";
4031
4159
  const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
4160
+ clearPausedMarker(this.getInstanceDir(instanceName));
4032
4161
  const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
4033
4162
  classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId, selectedBackend);
4034
4163
  // Bind this classic instance to the bot that started it (authoritative), so
@@ -4054,6 +4183,7 @@ When users create specialized instances, suggest these configurations:
4054
4183
  return t("classic.no_agent");
4055
4184
  this.instanceWorldBinding.delete(ch.instanceName);
4056
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));
4057
4187
  this.reregisterClassicChannels();
4058
4188
  this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
4059
4189
  return t("classic.stopped");
@@ -4642,6 +4772,10 @@ When users create specialized instances, suggest these configurations:
4642
4772
  }));
4643
4773
  const enriched = [...fleetInstances, ...classicInstances].map(inst => {
4644
4774
  const config = this.fleetConfig?.instances[inst.name];
4775
+ const persistedInboundAt = readLastInboundAt(this.getInstanceDir(inst.name));
4776
+ const lastActivity = inst.classic
4777
+ ? Math.max(persistedInboundAt ?? 0, readClassicLastActivityAt(this.dataDir, inst.name) ?? 0) || null
4778
+ : (persistedInboundAt ?? this.lastActivityMs(inst.name)) || null;
4645
4779
  const backend = inst.classic
4646
4780
  ? this.classicChannels?.getBackendByInstance(inst.name, this.fleetConfig?.defaults.backend) ?? "claude-code"
4647
4781
  : config?.backend ?? "claude-code";
@@ -4663,7 +4797,7 @@ When users create specialized instances, suggest these configurations:
4663
4797
  general_topic: config?.general_topic ?? false,
4664
4798
  // User activity is persisted by the daemon, so both the board and
4665
4799
  // auto-pause retain an accurate age across fleet restarts.
4666
- lastActivity: (readLastInboundAt(this.getInstanceDir(inst.name)) ?? this.lastActivityMs(inst.name)) || null,
4800
+ lastActivity,
4667
4801
  currentTask,
4668
4802
  idle: this.getInstanceIdle(inst.name),
4669
4803
  state: this.getInstanceExecutionState(inst.name),