@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.
- package/dist/backend/grok.js +3 -1
- package/dist/backend/grok.js.map +1 -1
- package/dist/classic-channel-manager.d.ts +4 -0
- package/dist/classic-channel-manager.js +23 -0
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +31 -8
- 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 +22 -2
- package/dist/fleet-manager.js +139 -5
- 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.d.ts +2 -0
- package/dist/settings-api.js +102 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/ui/settings.html +153 -62
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
@@ -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
|
|
4800
|
+
lastActivity,
|
|
4667
4801
|
currentTask,
|
|
4668
4802
|
idle: this.getInstanceIdle(inst.name),
|
|
4669
4803
|
state: this.getInstanceExecutionState(inst.name),
|