@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.
- package/dist/cli.js +3 -2
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +12 -0
- package/dist/daemon.js +40 -20
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +20 -2
- package/dist/fleet-manager.js +117 -3
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +1 -0
- package/dist/instance-lifecycle.js +27 -6
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/pause-marker.d.ts +5 -0
- package/dist/pause-marker.js +36 -0
- package/dist/pause-marker.js.map +1 -0
- package/dist/settings-api.js +4 -0
- package/dist/settings-api.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -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
|
-
|
|
375
|
-
|
|
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");
|