@songsid/agend 2.1.5 → 2.1.6-beta.2
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/apply-job.d.ts +114 -0
- package/dist/apply-job.js +214 -0
- package/dist/apply-job.js.map +1 -0
- package/dist/backend/codex.d.ts +16 -0
- package/dist/backend/codex.js +50 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +114 -0
- package/dist/backend/credential-profile.js +242 -0
- package/dist/backend/credential-profile.js.map +1 -0
- package/dist/backend/kiro-auth-store.d.ts +25 -0
- package/dist/backend/kiro-auth-store.js +61 -0
- package/dist/backend/kiro-auth-store.js.map +1 -0
- package/dist/backend/kiro.d.ts +8 -0
- package/dist/backend/kiro.js +31 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +13 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/cli.js +113 -0
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +21 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +25 -0
- package/dist/daemon.js +157 -16
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-level-config.d.ts +42 -0
- package/dist/fleet-level-config.js +78 -0
- package/dist/fleet-level-config.js.map +1 -0
- package/dist/fleet-lock.d.ts +21 -0
- package/dist/fleet-lock.js +42 -8
- package/dist/fleet-lock.js.map +1 -1
- package/dist/fleet-manager.d.ts +130 -1
- package/dist/fleet-manager.js +611 -83
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +121 -0
- package/dist/instance-config-impact.d.ts +55 -0
- package/dist/instance-config-impact.js +152 -0
- package/dist/instance-config-impact.js.map +1 -0
- package/dist/locale.js +2 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.d.ts +5 -1
- package/dist/outbound-handlers.js +179 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +6 -5
- package/dist/outbound-schemas.js +1 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +45 -0
- package/dist/provider-probe.js +111 -0
- package/dist/provider-probe.js.map +1 -0
- package/dist/quickstart-api.d.ts +161 -0
- package/dist/quickstart-api.js +340 -0
- package/dist/quickstart-api.js.map +1 -0
- package/dist/quickstart.js +23 -50
- package/dist/quickstart.js.map +1 -1
- package/dist/self-restart-limit.d.ts +21 -0
- package/dist/self-restart-limit.js +129 -0
- package/dist/self-restart-limit.js.map +1 -0
- package/dist/settings-api.d.ts +15 -0
- package/dist/settings-api.js +126 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-auth.d.ts +113 -0
- package/dist/setup-auth.js +181 -0
- package/dist/setup-auth.js.map +1 -0
- package/dist/setup-form.d.ts +18 -0
- package/dist/setup-form.js +315 -0
- package/dist/setup-form.js.map +1 -0
- package/dist/setup-host.d.ts +162 -0
- package/dist/setup-host.js +496 -0
- package/dist/setup-host.js.map +1 -0
- package/dist/setup-marker.d.ts +8 -0
- package/dist/setup-marker.js +39 -0
- package/dist/setup-marker.js.map +1 -0
- package/dist/setup-tunnel-consent.d.ts +53 -0
- package/dist/setup-tunnel-consent.js +82 -0
- package/dist/setup-tunnel-consent.js.map +1 -0
- package/dist/tips.js +1 -1
- package/dist/tips.js.map +1 -1
- package/dist/transcript-sources.d.ts +12 -1
- package/dist/transcript-sources.js +19 -3
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +71 -0
- package/dist/tunnel/cloudflared.js +447 -0
- package/dist/tunnel/cloudflared.js.map +1 -0
- package/dist/tunnel/lease.d.ts +75 -0
- package/dist/tunnel/lease.js +209 -0
- package/dist/tunnel/lease.js.map +1 -0
- package/dist/tunnel/manager.d.ts +56 -0
- package/dist/tunnel/manager.js +167 -0
- package/dist/tunnel/manager.js.map +1 -0
- package/dist/tunnel/types.d.ts +119 -0
- package/dist/tunnel/types.js +33 -0
- package/dist/tunnel/types.js.map +1 -0
- package/dist/ui/dashboard.html +4 -3
- package/dist/ui/settings.html +640 -153
- package/dist/usage/providers.d.ts +16 -13
- package/dist/usage/providers.js +79 -53
- package/dist/usage/providers.js.map +1 -1
- package/dist/web-api.js +6 -4
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +75 -0
- package/dist/web-auth.js +208 -2
- package/dist/web-auth.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -5,7 +5,6 @@ import { freemem, totalmem, cpus } from "node:os";
|
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import { join, dirname, basename } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { isDeepStrictEqual } from "node:util";
|
|
9
8
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
10
9
|
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
|
|
11
10
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
@@ -59,9 +58,18 @@ import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
|
59
58
|
import { validateFleetConfig } from "./config-validator.js";
|
|
60
59
|
import { readLastInboundAt } from "./daemon.js";
|
|
61
60
|
import { clearPausedMarker } from "./pause-marker.js";
|
|
62
|
-
import { releaseProcessFleetLock } from "./fleet-lock.js";
|
|
61
|
+
import { isFleetStartCommandLine, readProcessCommandLine, releaseProcessFleetLock } from "./fleet-lock.js";
|
|
62
|
+
import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
|
|
63
|
+
import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
|
|
63
64
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
64
|
-
import { loadOrCreateWebToken,
|
|
65
|
+
import { decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
66
|
+
import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
|
|
67
|
+
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
68
|
+
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
69
|
+
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
70
|
+
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
71
|
+
import { instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
72
|
+
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
65
73
|
import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
66
74
|
import { launchFullRestartHelper } from "./full-restart.js";
|
|
67
75
|
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
@@ -179,31 +187,6 @@ const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
|
|
|
179
187
|
* emoji never changes the documented delivery-state protocol.
|
|
180
188
|
*/
|
|
181
189
|
const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
|
|
182
|
-
const HOT_INSTANCE_CONFIG_KEYS = new Set([
|
|
183
|
-
"tool_progress",
|
|
184
|
-
"reply_completion_guard",
|
|
185
|
-
"mcp_proxy_reply",
|
|
186
|
-
"auto_pause_after",
|
|
187
|
-
"warm_cap",
|
|
188
|
-
"display_name",
|
|
189
|
-
"description",
|
|
190
|
-
"tags",
|
|
191
|
-
"log_level",
|
|
192
|
-
]);
|
|
193
|
-
function splitHotColdConfig(config) {
|
|
194
|
-
const hot = {};
|
|
195
|
-
const cold = {};
|
|
196
|
-
for (const [key, value] of Object.entries(config)) {
|
|
197
|
-
(HOT_INSTANCE_CONFIG_KEYS.has(key) ? hot : cold)[key] = value;
|
|
198
|
-
}
|
|
199
|
-
return { hot, cold };
|
|
200
|
-
}
|
|
201
|
-
function hotConfigUpdate(config) {
|
|
202
|
-
const update = {};
|
|
203
|
-
for (const key of HOT_INSTANCE_CONFIG_KEYS)
|
|
204
|
-
update[key] = config[key] ?? null;
|
|
205
|
-
return update;
|
|
206
|
-
}
|
|
207
190
|
/**
|
|
208
191
|
* How long a delivery waits out a disconnected instance IPC before giving up.
|
|
209
192
|
*
|
|
@@ -383,6 +366,10 @@ export class FleetManager {
|
|
|
383
366
|
// reply, on cancel, or when a newer button supersedes it for the same
|
|
384
367
|
// instance. Per-button tracking means a failed delete never strands a button.
|
|
385
368
|
cancelButtons = new Map();
|
|
369
|
+
/** Latest publication generation per instance. Late results from older
|
|
370
|
+
* generations are retired without touching the current button. */
|
|
371
|
+
cancelButtonPublications = new Map();
|
|
372
|
+
nextCancelButtonPublicationGeneration = 0;
|
|
386
373
|
/** Pending idle-edge retirement, one timer per instance. */
|
|
387
374
|
cancelButtonIdleRetireTimers = new Map();
|
|
388
375
|
/** Duplicate-reply suppression across both the MCP and HTTP reply paths. */
|
|
@@ -468,7 +455,29 @@ export class FleetManager {
|
|
|
468
455
|
mirrorTimer = null;
|
|
469
456
|
// Web UI: SSE clients + auth token
|
|
470
457
|
sseClients = new Set();
|
|
471
|
-
|
|
458
|
+
/**
|
|
459
|
+
* Read from disk on every access rather than cached at startup: `agend
|
|
460
|
+
* web-token rotate` runs in a separate process, and a cached copy would keep
|
|
461
|
+
* authorizing revoked links and cookies until the fleet restarted.
|
|
462
|
+
*/
|
|
463
|
+
get webToken() { return readWebToken(this.dataDir); }
|
|
464
|
+
/**
|
|
465
|
+
* Set while a Settings apply job is driving the reconcile. The reconcile
|
|
466
|
+
* stays the single doer; it just says out loud what it is doing to whom, so
|
|
467
|
+
* the job's rows are the work rather than a prediction of it.
|
|
468
|
+
*/
|
|
469
|
+
applyJobStoreCache = null;
|
|
470
|
+
/** The apply that currently owns the reconcile slot, reserved synchronously
|
|
471
|
+
* so a second request cannot slip in before the first one starts working. */
|
|
472
|
+
activeApplyJobId = null;
|
|
473
|
+
/** The fleet-level signature this process actually came up on. */
|
|
474
|
+
appliedFleetLevel = null;
|
|
475
|
+
/** The config behind that signature, kept so a "needs restart" log can name
|
|
476
|
+
* which keys moved rather than just asserting that something did. */
|
|
477
|
+
startupFleetConfig = null;
|
|
478
|
+
/** Set when the file on disk and the in-memory config disagree on a
|
|
479
|
+
* startup-only key at startup. See checkStartupSignatureConsistency(). */
|
|
480
|
+
fleetSignatureMismatch = null;
|
|
472
481
|
viewToken = null;
|
|
473
482
|
healthServerListening = false;
|
|
474
483
|
constructor(dataDir) {
|
|
@@ -544,26 +553,48 @@ export class FleetManager {
|
|
|
544
553
|
this.scheduleReconcile();
|
|
545
554
|
}
|
|
546
555
|
scheduleReconcile() {
|
|
547
|
-
|
|
556
|
+
const started = this.startExclusiveReconcile();
|
|
557
|
+
if (!started) {
|
|
548
558
|
this.reloadPending = true;
|
|
549
559
|
this.logger.info("Config reconciliation already running — coalesced reload request");
|
|
550
560
|
return;
|
|
551
561
|
}
|
|
552
|
-
|
|
553
|
-
this.reconcileInFlight = this.reconcileInstances()
|
|
554
|
-
.catch(err => {
|
|
562
|
+
started.catch(err => {
|
|
555
563
|
// Almost always a YAML parse error. Log-only meant the user edited
|
|
556
564
|
// fleet.yaml, sent SIGHUP, and got no reaction and no explanation.
|
|
557
565
|
this.logger.error({ err }, "SIGHUP config reload failed");
|
|
558
566
|
const message = err instanceof Error ? err.message : String(err);
|
|
559
567
|
this.notifyFleetError(t("fleet.reload_failed", message));
|
|
560
|
-
})
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Take the reconcile slot, or refuse.
|
|
572
|
+
*
|
|
573
|
+
* Only one reconcile may touch lifecycle and config at a time — two of them
|
|
574
|
+
* stop and start the same instance in parallel. SIGHUP and a Settings apply
|
|
575
|
+
* are the same operation from two entrances, so they share the one slot: the
|
|
576
|
+
* signal coalesces into a pending replay, the apply is told the fleet is busy.
|
|
577
|
+
*
|
|
578
|
+
* The returned promise is the caller's to handle; the stored one is already
|
|
579
|
+
* handled, so a rejection never escapes as an unhandled rejection.
|
|
580
|
+
*/
|
|
581
|
+
startExclusiveReconcile(observer) {
|
|
582
|
+
if (this.reconcileInFlight)
|
|
583
|
+
return null;
|
|
584
|
+
this.reloadPending = false;
|
|
585
|
+
let settle;
|
|
586
|
+
const caller = new Promise((resolve, reject) => {
|
|
587
|
+
settle = (err, outcome) => (err ? reject(err instanceof Error ? err : new Error(String(err))) : resolve(outcome ?? {}));
|
|
588
|
+
});
|
|
589
|
+
this.reconcileInFlight = this.reconcileInstances(observer)
|
|
590
|
+
.then(outcome => settle(null, outcome), err => settle(err))
|
|
561
591
|
.finally(() => {
|
|
562
592
|
this.reconcileInFlight = null;
|
|
563
593
|
if (this.reloadPending && this.startupComplete) {
|
|
564
594
|
this.scheduleReconcile();
|
|
565
595
|
}
|
|
566
596
|
});
|
|
597
|
+
return caller;
|
|
567
598
|
}
|
|
568
599
|
/**
|
|
569
600
|
* Is the fleet going down (or coming back up) on purpose?
|
|
@@ -579,6 +610,38 @@ export class FleetManager {
|
|
|
579
610
|
}
|
|
580
611
|
finishStartup() {
|
|
581
612
|
this.startupComplete = true;
|
|
613
|
+
// Resolve whatever a previous run — or a setup host that crashed — left
|
|
614
|
+
// behind. A tunnel nobody is tracking is a public entrance nobody is
|
|
615
|
+
// watching, and the fleet starting is the moment there is finally a process
|
|
616
|
+
// around to notice. Never throws: a lease that cannot be resolved blocks
|
|
617
|
+
// the next tunnel and says so, it does not block the fleet.
|
|
618
|
+
void reapStaleTunnel(this.dataDir)
|
|
619
|
+
.then(outcome => {
|
|
620
|
+
if (outcome.kind === "manual")
|
|
621
|
+
this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
|
|
622
|
+
else if (outcome.kind === "reaped")
|
|
623
|
+
this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
|
|
624
|
+
})
|
|
625
|
+
.catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
|
|
626
|
+
// An existing installation has never written the setup marker — it predates
|
|
627
|
+
// it — so `agend setup` would open a pre-fleet form for a fleet that plainly
|
|
628
|
+
// exists. A fleet that just came up on a config with agents in it is proof
|
|
629
|
+
// enough that setup happened.
|
|
630
|
+
if (Object.keys(this.fleetConfig?.instances ?? {}).length > 0 && !isSetupComplete(this.dataDir)) {
|
|
631
|
+
markSetupComplete(this.dataDir);
|
|
632
|
+
}
|
|
633
|
+
// After slimFleetConfigAtStartup() and the general/topic fixups, all of
|
|
634
|
+
// which may rewrite fleet.yaml — the baseline has to be what this process
|
|
635
|
+
// is actually running, compared against what a reconcile would load.
|
|
636
|
+
this.appliedFleetLevel = this.fleetLevelSignature();
|
|
637
|
+
this.startupFleetConfig = this.fleetConfig ? structuredClone(this.fleetConfig) : null;
|
|
638
|
+
this.checkStartupSignatureConsistency();
|
|
639
|
+
// A job from the process that just died cannot still be running here. The
|
|
640
|
+
// restart applied the saved config to every instance, so its open rows are
|
|
641
|
+
// finished — by the restart, which is what the user needs told.
|
|
642
|
+
for (const settled of this.applyJobs.settleAfterRestart()) {
|
|
643
|
+
this.logger.info({ jobId: settled.id }, "Settings apply job settled by fleet restart");
|
|
644
|
+
}
|
|
582
645
|
// We are the post-update fleet: the update is over by definition. Clearing
|
|
583
646
|
// it here (rather than in the update command, which exits before the new
|
|
584
647
|
// fleet is up) is what keeps the quiet window from outliving the restart.
|
|
@@ -1112,31 +1175,43 @@ export class FleetManager {
|
|
|
1112
1175
|
*/
|
|
1113
1176
|
getActiveUsageProviderIds() {
|
|
1114
1177
|
const providers = new Set();
|
|
1115
|
-
|
|
1178
|
+
// Per instance, not per backend: two agents on two kiro subscriptions are
|
|
1179
|
+
// two rows, and filtering by the bare backend id would hide both.
|
|
1180
|
+
for (const [name, backend, profile] of this.activeBackendBindings()) {
|
|
1181
|
+
void name;
|
|
1116
1182
|
const provider = usageProviderIdForBackend(backend);
|
|
1117
|
-
if (provider)
|
|
1118
|
-
|
|
1183
|
+
if (!provider)
|
|
1184
|
+
continue;
|
|
1185
|
+
providers.add(profile ? `${provider}:${profile}` : provider);
|
|
1119
1186
|
}
|
|
1120
1187
|
return providers;
|
|
1121
1188
|
}
|
|
1122
|
-
/**
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
const
|
|
1189
|
+
/** `[instance, effective backend, credential profile]` for everything that is
|
|
1190
|
+
* running or paused — the one place both usage views agree on who is live. */
|
|
1191
|
+
activeBackendBindings() {
|
|
1192
|
+
const bindings = [];
|
|
1193
|
+
const add = (name, backend, profile) => {
|
|
1126
1194
|
const status = this.getInstanceStatus(name);
|
|
1127
1195
|
if (status !== "running" && status !== "paused")
|
|
1128
1196
|
return;
|
|
1129
1197
|
if (backend)
|
|
1130
|
-
|
|
1198
|
+
bindings.push([name, backend, profile]);
|
|
1131
1199
|
};
|
|
1132
1200
|
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
1133
1201
|
// loadFleetConfig() has already merged the fleet default into each row.
|
|
1134
|
-
|
|
1202
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
1203
|
+
add(name, backend, instanceCredentialProfile(config, this.fleetConfig?.defaults, backend));
|
|
1135
1204
|
}
|
|
1136
1205
|
for (const channel of this.classicChannels?.getAll() ?? []) {
|
|
1137
|
-
|
|
1206
|
+
const backend = this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend);
|
|
1207
|
+
// Classic channels carry no backend_options, so they run the shared login.
|
|
1208
|
+
add(channel.instanceName, backend, null);
|
|
1138
1209
|
}
|
|
1139
|
-
return
|
|
1210
|
+
return bindings;
|
|
1211
|
+
}
|
|
1212
|
+
/** Effective backends with a running or persisted-paused fleet/Classic instance. */
|
|
1213
|
+
getActiveBackendIds() {
|
|
1214
|
+
return new Set(this.activeBackendBindings().map(([, backend]) => backend));
|
|
1140
1215
|
}
|
|
1141
1216
|
isClassicInstance(name) {
|
|
1142
1217
|
return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
|
|
@@ -1181,12 +1256,17 @@ export class FleetManager {
|
|
|
1181
1256
|
}
|
|
1182
1257
|
scheduleIdleButtonRetirement(name) {
|
|
1183
1258
|
this.cancelIdleButtonRetirement(name);
|
|
1259
|
+
// Bind this edge to the publication that was current when idle was
|
|
1260
|
+
// observed. A later inbound may start a new generation before this timer
|
|
1261
|
+
// fires; the old edge must not mark that newer button for retirement.
|
|
1262
|
+
const publication = this.cancelButtonPublications.get(name);
|
|
1184
1263
|
const timer = setTimeout(() => {
|
|
1185
1264
|
// Ignore a superseded timer even if it was already queued to run.
|
|
1186
1265
|
if (this.cancelButtonIdleRetireTimers.get(name) !== timer)
|
|
1187
1266
|
return;
|
|
1188
1267
|
this.cancelButtonIdleRetireTimers.delete(name);
|
|
1189
1268
|
if (this.getInstanceExecutionState(name) === "idle") {
|
|
1269
|
+
this.markCancelButtonPublicationForRetirement(name, publication);
|
|
1190
1270
|
this.retireInstanceButtons(name);
|
|
1191
1271
|
}
|
|
1192
1272
|
}, CANCEL_BTN_IDLE_RETIRE_GRACE_MS);
|
|
@@ -1539,7 +1619,7 @@ export class FleetManager {
|
|
|
1539
1619
|
if (!wasRunning)
|
|
1540
1620
|
return;
|
|
1541
1621
|
const hotOnly = changedFields.length > 0
|
|
1542
|
-
&& changedFields.every(field => field
|
|
1622
|
+
&& changedFields.every(field => CLASSIC_HOT_CONFIG_KEYS.has(field));
|
|
1543
1623
|
if (hotOnly) {
|
|
1544
1624
|
this.applyHotConfigUpdate(instanceName, this.classicBehaviorUpdate(instanceName));
|
|
1545
1625
|
this.logger.info({ instanceName, fields: changedFields }, "Classic instance hot config reloaded");
|
|
@@ -2165,7 +2245,9 @@ export class FleetManager {
|
|
|
2165
2245
|
}
|
|
2166
2246
|
/** Initialize auth before any adapter can answer /dashboard. */
|
|
2167
2247
|
initializeWebAuthTokens() {
|
|
2168
|
-
|
|
2248
|
+
// Creates web.token if absent; the value is then read back per request by
|
|
2249
|
+
// the `webToken` getter, so nothing is cached here.
|
|
2250
|
+
loadOrCreateWebToken(this.dataDir);
|
|
2169
2251
|
this.viewToken = randomBytes(24).toString("hex");
|
|
2170
2252
|
const viewTokenPath = join(this.dataDir, "view.token");
|
|
2171
2253
|
writeFileSync(viewTokenPath, this.viewToken, { encoding: "utf8", mode: 0o600 });
|
|
@@ -7098,6 +7180,30 @@ export class FleetManager {
|
|
|
7098
7180
|
}
|
|
7099
7181
|
return false;
|
|
7100
7182
|
}
|
|
7183
|
+
beginCancelButtonPublication(instanceName, correlationId) {
|
|
7184
|
+
// A new handoff supersedes any idle timer left by the previous turn. If the
|
|
7185
|
+
// instance is still idle after this publication, the post-await level check
|
|
7186
|
+
// below starts a fresh grace period for this generation.
|
|
7187
|
+
this.cancelIdleButtonRetirement(instanceName);
|
|
7188
|
+
const publication = {
|
|
7189
|
+
generation: ++this.nextCancelButtonPublicationGeneration,
|
|
7190
|
+
correlationId,
|
|
7191
|
+
inFlight: true,
|
|
7192
|
+
retirePending: false,
|
|
7193
|
+
};
|
|
7194
|
+
this.cancelButtonPublications.set(instanceName, publication);
|
|
7195
|
+
return publication;
|
|
7196
|
+
}
|
|
7197
|
+
/** Remember a clear/cancel/idle decision that arrived before notifyAlert
|
|
7198
|
+
* returned a message id. `expected` fences an idle timer to its generation. */
|
|
7199
|
+
markCancelButtonPublicationForRetirement(instanceName, expected) {
|
|
7200
|
+
const publication = this.cancelButtonPublications.get(instanceName);
|
|
7201
|
+
if (!publication?.inFlight)
|
|
7202
|
+
return;
|
|
7203
|
+
if (expected && publication !== expected)
|
|
7204
|
+
return;
|
|
7205
|
+
publication.retirePending = true;
|
|
7206
|
+
}
|
|
7101
7207
|
async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
|
|
7102
7208
|
// Post first, retire after (see the tail of this method). Retiring up front
|
|
7103
7209
|
// meant that from the delete until the new message came back — a chat API
|
|
@@ -7136,6 +7242,7 @@ export class FleetManager {
|
|
|
7136
7242
|
this.logger.warn({ instanceName, topicId, groupId }, "Cannot address cancel button (no chat id resolved)");
|
|
7137
7243
|
return;
|
|
7138
7244
|
}
|
|
7245
|
+
const publication = this.beginCancelButtonPublication(instanceName, correlationId);
|
|
7139
7246
|
try {
|
|
7140
7247
|
const sent = await adapter.notifyAlert(chatId, {
|
|
7141
7248
|
type: "cancel",
|
|
@@ -7143,6 +7250,7 @@ export class FleetManager {
|
|
|
7143
7250
|
message: "👀 處理中…",
|
|
7144
7251
|
choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
|
|
7145
7252
|
}, threadId ? { threadId } : undefined);
|
|
7253
|
+
publication.inFlight = false;
|
|
7146
7254
|
const entry = {
|
|
7147
7255
|
instanceName,
|
|
7148
7256
|
adapterId,
|
|
@@ -7164,6 +7272,16 @@ export class FleetManager {
|
|
|
7164
7272
|
// empty even if the daemon's reset broadcast is still in flight.
|
|
7165
7273
|
toolProgress: preserveProgress ? this.instanceProgress.get(instanceName) : undefined,
|
|
7166
7274
|
};
|
|
7275
|
+
// A newer send started while this API call was pending. Track this late
|
|
7276
|
+
// message just long enough for the normal bounded delete/retry machinery
|
|
7277
|
+
// to remove it; critically, do not sweep the newer generation.
|
|
7278
|
+
if (this.cancelButtonPublications.get(instanceName) !== publication) {
|
|
7279
|
+
this.cancelButtons.set(sent.messageId, entry);
|
|
7280
|
+
this.persistCancelButtons();
|
|
7281
|
+
this.logger.debug({ instanceName, messageId: sent.messageId, generation: publication.generation }, "Retiring superseded cancel-button publication");
|
|
7282
|
+
this.retireButton(entry);
|
|
7283
|
+
return;
|
|
7284
|
+
}
|
|
7167
7285
|
this.startProgressTicker(entry);
|
|
7168
7286
|
// Idle-check backstop: every 5min, if the instance is idle, retire the
|
|
7169
7287
|
// button. Covers turns that end without hitting a clear trigger (reply /
|
|
@@ -7200,8 +7318,30 @@ export class FleetManager {
|
|
|
7200
7318
|
}
|
|
7201
7319
|
this.persistCancelButtons();
|
|
7202
7320
|
this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
|
|
7321
|
+
// Retirement is normally edge-triggered, but the edge (or an explicit
|
|
7322
|
+
// reply/cancel clear) may have happened while notifyAlert was in flight.
|
|
7323
|
+
// Reconcile the level after publication so a late button cannot resurrect
|
|
7324
|
+
// on an already-idle instance. Preserve the existing two-second grace:
|
|
7325
|
+
// a working transition cancels this timer just as it does for a normal
|
|
7326
|
+
// idle edge.
|
|
7327
|
+
if (publication.retirePending) {
|
|
7328
|
+
this.retireButton(entry);
|
|
7329
|
+
}
|
|
7330
|
+
else if (this.getInstanceExecutionState(instanceName) === "idle") {
|
|
7331
|
+
this.scheduleIdleButtonRetirement(instanceName);
|
|
7332
|
+
}
|
|
7203
7333
|
}
|
|
7204
7334
|
catch (e) {
|
|
7335
|
+
if (this.cancelButtonPublications.get(instanceName) === publication) {
|
|
7336
|
+
this.cancelButtonPublications.delete(instanceName);
|
|
7337
|
+
// beginCancelButtonPublication cancelled the previous turn's idle
|
|
7338
|
+
// timer so it could not act on this generation. If the provider POST
|
|
7339
|
+
// failed while the instance remained idle, restore that retirement
|
|
7340
|
+
// opportunity for any older button still on screen.
|
|
7341
|
+
if (this.getInstanceExecutionState(instanceName) === "idle") {
|
|
7342
|
+
this.scheduleIdleButtonRetirement(instanceName);
|
|
7343
|
+
}
|
|
7344
|
+
}
|
|
7205
7345
|
this.logger.warn({ err: e.message, instanceName }, "Failed to send cancel button");
|
|
7206
7346
|
}
|
|
7207
7347
|
}
|
|
@@ -7554,6 +7694,7 @@ export class FleetManager {
|
|
|
7554
7694
|
}
|
|
7555
7695
|
/** Retire all cancel buttons for an instance — on reply or cancel. */
|
|
7556
7696
|
clearCancelButton(instanceName) {
|
|
7697
|
+
this.markCancelButtonPublicationForRetirement(instanceName);
|
|
7557
7698
|
this.retireInstanceButtons(instanceName);
|
|
7558
7699
|
}
|
|
7559
7700
|
/** Retire the cross-instance button matching a delegate→report correlation id.
|
|
@@ -7562,6 +7703,11 @@ export class FleetManager {
|
|
|
7562
7703
|
clearCancelButtonByCorrelation(correlationId) {
|
|
7563
7704
|
if (!correlationId)
|
|
7564
7705
|
return;
|
|
7706
|
+
for (const [instanceName, publication] of this.cancelButtonPublications) {
|
|
7707
|
+
if (publication.inFlight && publication.correlationId === correlationId) {
|
|
7708
|
+
this.markCancelButtonPublicationForRetirement(instanceName, publication);
|
|
7709
|
+
}
|
|
7710
|
+
}
|
|
7565
7711
|
for (const e of [...this.cancelButtons.values()]) {
|
|
7566
7712
|
if (e.correlationId === correlationId)
|
|
7567
7713
|
this.retireButton(e);
|
|
@@ -10299,6 +10445,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10299
10445
|
clearInterval(entry.progressTimer);
|
|
10300
10446
|
}
|
|
10301
10447
|
this.cancelButtons.clear();
|
|
10448
|
+
this.cancelButtonPublications.clear();
|
|
10302
10449
|
for (const timer of this.cancelButtonIdleRetireTimers.values())
|
|
10303
10450
|
clearTimeout(timer);
|
|
10304
10451
|
this.cancelButtonIdleRetireTimers.clear();
|
|
@@ -10512,9 +10659,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10512
10659
|
* Whitelisted runtime fields are pushed into live daemons; all other instance
|
|
10513
10660
|
* fields, plus cold fleet-level settings, retain restart semantics.
|
|
10514
10661
|
*/
|
|
10515
|
-
async reconcileInstances() {
|
|
10662
|
+
async reconcileInstances(observe) {
|
|
10516
10663
|
if (!this.configPath)
|
|
10517
|
-
return;
|
|
10664
|
+
return {};
|
|
10518
10665
|
const oldConfig = this.fleetConfig;
|
|
10519
10666
|
const previousRawConfig = this.rawFleetConfig;
|
|
10520
10667
|
const previousRawDocument = this.rawFleetDocument;
|
|
@@ -10557,7 +10704,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10557
10704
|
? t("fleet.reload_removed_all", oldCount)
|
|
10558
10705
|
: t("fleet.reload_removed_half", oldCount, newCount);
|
|
10559
10706
|
this.notifyFleetError(t("fleet.reload_rejected", why));
|
|
10560
|
-
|
|
10707
|
+
// Reported, not swallowed: an apply job whose config was refused used to
|
|
10708
|
+
// mark every row done and tell the user "changes applied".
|
|
10709
|
+
return { rejected: why };
|
|
10561
10710
|
}
|
|
10562
10711
|
// Classic behavior settings share the fleet defaults but are not entries
|
|
10563
10712
|
// in fleet.yaml. Snapshot the old effective chain before reloading the
|
|
@@ -10588,29 +10737,31 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10588
10737
|
this.scheduler?.reload();
|
|
10589
10738
|
const newInstances = this.fleetConfig.instances;
|
|
10590
10739
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
10591
|
-
//
|
|
10592
|
-
//
|
|
10593
|
-
|
|
10594
|
-
|
|
10595
|
-
|
|
10596
|
-
|
|
10597
|
-
|
|
10598
|
-
|
|
10599
|
-
|
|
10600
|
-
|
|
10601
|
-
|
|
10602
|
-
|
|
10603
|
-
const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldDefaultCold });
|
|
10604
|
-
const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: newDefaultCold });
|
|
10605
|
-
if (oldFleetLevel !== newFleetLevel) {
|
|
10606
|
-
this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect");
|
|
10740
|
+
// Only what a fresh process can adopt, and only relative to the signature
|
|
10741
|
+
// this process came up on: a Settings edit mutates this.fleetConfig in place
|
|
10742
|
+
// before the reload, so comparing the pre-load copy sees nothing at all.
|
|
10743
|
+
const newFleetLevel = this.fleetLevelSignature();
|
|
10744
|
+
if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel) {
|
|
10745
|
+
this.logger.warn({
|
|
10746
|
+
keys: fleetLevelDifferences(this.startupFleetConfig, this.fleetConfig),
|
|
10747
|
+
}, "Fleet-level config changed — restart AgEnD for it to take effect");
|
|
10748
|
+
// Terminal, and deliberately not "done": this reconcile cannot adopt a
|
|
10749
|
+
// fleet-level change, and saying otherwise would claim AgEnD is running
|
|
10750
|
+
// on a configuration it is not running on.
|
|
10751
|
+
observe?.(APPLY_FLEET_TARGET, "restart", "restart-required");
|
|
10607
10752
|
}
|
|
10608
10753
|
// Stop removed instances (skip classic bot instances — they're managed by classicBot.yaml)
|
|
10609
10754
|
const classicNames = new Set(this.classicChannels?.getAll().map(ch => ch.instanceName) ?? []);
|
|
10610
10755
|
for (const name of this.daemons.keys()) {
|
|
10611
10756
|
if (!(name in newInstances) && !classicNames.has(name)) {
|
|
10612
10757
|
this.logger.info({ name }, "Instance removed from config — stopping");
|
|
10613
|
-
|
|
10758
|
+
observe?.(name, "restart", "running");
|
|
10759
|
+
await this.stopInstance(name)
|
|
10760
|
+
.then(() => observe?.(name, "restart", "done"))
|
|
10761
|
+
.catch(err => {
|
|
10762
|
+
observe?.(name, "restart", "failed", err.message);
|
|
10763
|
+
this.logger.error({ err, name }, "Failed to stop removed instance");
|
|
10764
|
+
});
|
|
10614
10765
|
}
|
|
10615
10766
|
}
|
|
10616
10767
|
// Start new + reconcile modified instances. Hot values are always sent as a
|
|
@@ -10620,20 +10771,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10620
10771
|
if (!this.daemons.has(name)) {
|
|
10621
10772
|
// New instance — startInstance already calls connectIpcToInstance
|
|
10622
10773
|
this.logger.info({ name }, "New instance in config — starting");
|
|
10774
|
+
observe?.(name, "restart", "running");
|
|
10623
10775
|
await this.startInstanceUnattended(name, config, topicMode, "new instance");
|
|
10776
|
+
observe?.(name, "restart", "done");
|
|
10624
10777
|
}
|
|
10625
10778
|
else if (oldConfig?.instances[name]) {
|
|
10626
10779
|
const daemon = this.daemons.get(name);
|
|
10627
10780
|
const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
|
|
10628
|
-
const
|
|
10629
|
-
|
|
10630
|
-
// Every field not explicitly classified hot is cold by default.
|
|
10631
|
-
if (!isDeepStrictEqual(oldParts.cold, newParts.cold)) {
|
|
10781
|
+
const change = classifyInstanceChange(runtimeConfig, config);
|
|
10782
|
+
if (change === "restart") {
|
|
10632
10783
|
this.logger.info({ name }, "Instance config changed — restarting");
|
|
10784
|
+
observe?.(name, "restart", "running");
|
|
10633
10785
|
await this.stopInstance(name).catch(() => { });
|
|
10634
10786
|
await this.startInstanceUnattended(name, config, topicMode, "modified instance");
|
|
10787
|
+
observe?.(name, "restart", "done");
|
|
10635
10788
|
}
|
|
10636
|
-
else if (
|
|
10789
|
+
else if (change === "hot") {
|
|
10790
|
+
observe?.(name, "hot", "running");
|
|
10637
10791
|
const update = hotConfigUpdate(config);
|
|
10638
10792
|
const ipc = this.instanceIpcClients.get(name);
|
|
10639
10793
|
const sent = ipc?.connected === true && ipc.send({ type: "config_update", config: update });
|
|
@@ -10644,6 +10798,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10644
10798
|
this.logger.warn({ name }, "Config-update IPC unavailable — applied hot config in-process");
|
|
10645
10799
|
}
|
|
10646
10800
|
this.logger.info({ name, fields: [...HOT_INSTANCE_CONFIG_KEYS] }, "Instance hot config reloaded");
|
|
10801
|
+
observe?.(name, "hot", "done");
|
|
10647
10802
|
}
|
|
10648
10803
|
}
|
|
10649
10804
|
}
|
|
@@ -10661,25 +10816,362 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10661
10816
|
const autoPauseAfter = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
|
|
10662
10817
|
if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter) {
|
|
10663
10818
|
this.logger.info({ instanceName: ch.instanceName }, "Classic cold config changed — restarting");
|
|
10819
|
+
observe?.(ch.instanceName, "restart", "running");
|
|
10664
10820
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
10665
10821
|
await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
|
|
10822
|
+
observe?.(ch.instanceName, "restart", "done");
|
|
10666
10823
|
continue;
|
|
10667
10824
|
}
|
|
10668
10825
|
const toolProgress = this.classicChannels.getToolProgress(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.tool_progress);
|
|
10669
10826
|
const replyCompletionGuard = this.classicChannels.getReplyCompletionGuard(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.reply_completion_guard);
|
|
10670
10827
|
if (old.toolProgress !== toolProgress || old.replyCompletionGuard !== replyCompletionGuard) {
|
|
10828
|
+
observe?.(ch.instanceName, "hot", "running");
|
|
10671
10829
|
this.applyHotConfigUpdate(ch.instanceName, {
|
|
10672
10830
|
tool_progress: toolProgress,
|
|
10673
10831
|
reply_completion_guard: replyCompletionGuard,
|
|
10674
10832
|
});
|
|
10675
10833
|
this.logger.info({ instanceName: ch.instanceName }, "Classic inherited hot config reloaded");
|
|
10834
|
+
observe?.(ch.instanceName, "hot", "done");
|
|
10676
10835
|
}
|
|
10677
10836
|
}
|
|
10678
10837
|
}
|
|
10679
10838
|
// warm_cap is fleet-owned; enforce the reloaded value immediately against
|
|
10680
10839
|
// currently idle instances instead of waiting for a future state edge.
|
|
10681
10840
|
this.enforceWarmCap();
|
|
10841
|
+
// appliedFleetLevel is deliberately NOT updated here. It means "the
|
|
10842
|
+
// fleet-level config this process started on"; a reconcile does not restart
|
|
10843
|
+
// the process, so moving it would erase the fact that a restart is still
|
|
10844
|
+
// owed and silence every later reminder.
|
|
10682
10845
|
this.logger.info({ running: this.daemons.size, configured: Object.keys(newInstances).length }, "Reconcile complete");
|
|
10846
|
+
return {};
|
|
10847
|
+
}
|
|
10848
|
+
/**
|
|
10849
|
+
* `{channel, cold defaults}` as one comparable string — the part of the config
|
|
10850
|
+
* a running fleet process cannot adopt without restarting.
|
|
10851
|
+
*/
|
|
10852
|
+
fleetLevelSignature(config = this.fleetConfig) {
|
|
10853
|
+
return fleetLevelSignature(config);
|
|
10854
|
+
}
|
|
10855
|
+
/**
|
|
10856
|
+
* The config a reconcile is about to load, not the one held in memory.
|
|
10857
|
+
*
|
|
10858
|
+
* Settings mutates the in-memory object and writes the file; only the file
|
|
10859
|
+
* goes back through defaults expansion. Forecasting from memory therefore
|
|
10860
|
+
* misses every instance that a changed fleet default will restart — the user
|
|
10861
|
+
* is told "restart AgEnD" and not told that five agents are about to go down.
|
|
10862
|
+
*/
|
|
10863
|
+
nextFleetConfig() {
|
|
10864
|
+
if (!this.configPath)
|
|
10865
|
+
return this.fleetConfig;
|
|
10866
|
+
try {
|
|
10867
|
+
return loadFleetConfig(this.configPath);
|
|
10868
|
+
}
|
|
10869
|
+
catch (err) {
|
|
10870
|
+
// An unparseable file is the reconcile's problem to report; the forecast
|
|
10871
|
+
// falls back to what is running rather than failing the request.
|
|
10872
|
+
this.logger.debug({ err }, "Apply plan fell back to the in-memory config");
|
|
10873
|
+
return this.fleetConfig;
|
|
10874
|
+
}
|
|
10875
|
+
}
|
|
10876
|
+
/**
|
|
10877
|
+
* Does the config this process is running match the one a reconcile would
|
|
10878
|
+
* load off disk?
|
|
10879
|
+
*
|
|
10880
|
+
* If not, every apply reports a fleet-level change that a restart cannot
|
|
10881
|
+
* clear — restart, recompute, disagree again — a self-sustaining loop that
|
|
10882
|
+
* the rate limit can only slow to three naggings an hour. Startup rewrites
|
|
10883
|
+
* the file in three places before this point (slimFleetConfigAtStartup, the
|
|
10884
|
+
* general auto-create, the general fixup), so the two can genuinely diverge.
|
|
10885
|
+
*
|
|
10886
|
+
* An offer to restart that cannot possibly succeed is worse than no offer, so
|
|
10887
|
+
* the panel shows the mismatch instead of a button.
|
|
10888
|
+
*/
|
|
10889
|
+
checkStartupSignatureConsistency() {
|
|
10890
|
+
if (!this.configPath) {
|
|
10891
|
+
this.fleetSignatureMismatch = null;
|
|
10892
|
+
return;
|
|
10893
|
+
}
|
|
10894
|
+
let onDisk;
|
|
10895
|
+
try {
|
|
10896
|
+
onDisk = loadFleetConfig(this.configPath);
|
|
10897
|
+
}
|
|
10898
|
+
catch (err) {
|
|
10899
|
+
this.logger.warn({ err }, "Could not re-read fleet.yaml to check the startup signature");
|
|
10900
|
+
this.fleetSignatureMismatch = null;
|
|
10901
|
+
return;
|
|
10902
|
+
}
|
|
10903
|
+
if (fleetLevelSignature(onDisk) === this.appliedFleetLevel) {
|
|
10904
|
+
this.fleetSignatureMismatch = null;
|
|
10905
|
+
return;
|
|
10906
|
+
}
|
|
10907
|
+
this.fleetSignatureMismatch = fleetLevelDifferences(this.fleetConfig, onDisk);
|
|
10908
|
+
this.logger.warn({
|
|
10909
|
+
keys: this.fleetSignatureMismatch,
|
|
10910
|
+
configPath: this.configPath,
|
|
10911
|
+
}, "fleet.yaml and the running configuration disagree on startup-only keys — every apply will ask for a restart that cannot clear it");
|
|
10912
|
+
}
|
|
10913
|
+
/**
|
|
10914
|
+
* Is a live adapter already long-polling this bot token?
|
|
10915
|
+
*
|
|
10916
|
+
* Telegram's `getUpdates` has exactly one consumer: a second poller takes
|
|
10917
|
+
* turns with the first and both miss messages. The setup wizard's "post in
|
|
10918
|
+
* the group and I'll detect it" step is a second poller, so it has to know
|
|
10919
|
+
* when the answer is "not against this token, not while I'm running".
|
|
10920
|
+
*/
|
|
10921
|
+
isBotTokenInUse(token) {
|
|
10922
|
+
if (!token)
|
|
10923
|
+
return false;
|
|
10924
|
+
const configured = this.fleetConfig?.channels
|
|
10925
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
10926
|
+
for (const channel of configured) {
|
|
10927
|
+
const envVar = channel?.bot_token_env;
|
|
10928
|
+
if (!envVar)
|
|
10929
|
+
continue;
|
|
10930
|
+
// Compare the value, not the variable name: the same token can be
|
|
10931
|
+
// reached through a differently named variable.
|
|
10932
|
+
if (process.env[envVar] === token)
|
|
10933
|
+
return true;
|
|
10934
|
+
}
|
|
10935
|
+
return false;
|
|
10936
|
+
}
|
|
10937
|
+
/** Non-null when startup found the running config and fleet.yaml disagreeing. */
|
|
10938
|
+
fleetSignatureMismatchKeys() {
|
|
10939
|
+
return this.fleetSignatureMismatch;
|
|
10940
|
+
}
|
|
10941
|
+
/**
|
|
10942
|
+
* Restart AgEnD itself on behalf of a Settings apply.
|
|
10943
|
+
*
|
|
10944
|
+
* Deliberately a separate action from Apply: the panel is reachable from
|
|
10945
|
+
* outside the LAN, and "restart the whole fleet" must never be something a
|
|
10946
|
+
* single Apply click can carry along with it.
|
|
10947
|
+
*
|
|
10948
|
+
* The order below is the safety envelope, and the order matters:
|
|
10949
|
+
* concurrency and consistency first (cheap, and a restart during a reconcile
|
|
10950
|
+
* is the dangerous one), then the state checks, then the rate limit, then the
|
|
10951
|
+
* audit notice — and only once all of that holds is the attempt written to
|
|
10952
|
+
* disk and fsynced, before anything spawns.
|
|
10953
|
+
*/
|
|
10954
|
+
async requestSettingsSelfRestart(jobId, key) {
|
|
10955
|
+
// A retry after a lost response must not restart a second time. The key is
|
|
10956
|
+
// recorded on the job, which survives the restart it triggers.
|
|
10957
|
+
const already = this.applyJobs.findByRestartKey(key);
|
|
10958
|
+
if (already)
|
|
10959
|
+
return { ok: true, jobId: already.id, reused: true };
|
|
10960
|
+
if (this.reconcileInFlight || this.activeApplyJobId) {
|
|
10961
|
+
return { ok: false, status: 409, error: "a configuration reload is running — try again once it finishes" };
|
|
10962
|
+
}
|
|
10963
|
+
if (this.fleetSignatureMismatch) {
|
|
10964
|
+
// Restarting cannot clear this, so offering it would be a loop.
|
|
10965
|
+
return {
|
|
10966
|
+
ok: false,
|
|
10967
|
+
status: 409,
|
|
10968
|
+
error: `fleet.yaml and the running configuration disagree on ${this.fleetSignatureMismatch.join(", ")} — check fleet.log before restarting`,
|
|
10969
|
+
};
|
|
10970
|
+
}
|
|
10971
|
+
const job = this.applyJobs.get(jobId);
|
|
10972
|
+
const row = job?.targets.find(item => item.target === APPLY_FLEET_TARGET);
|
|
10973
|
+
if (!job || !row || row.status !== "restart-required") {
|
|
10974
|
+
return { ok: false, status: 409, error: "that apply has no pending fleet-level change" };
|
|
10975
|
+
}
|
|
10976
|
+
// The row alone is not enough: an old job keeps its row for the whole
|
|
10977
|
+
// retention window, so a change made and then reverted would still leave a
|
|
10978
|
+
// job that looks restartable. The live signature is the authority — and it
|
|
10979
|
+
// must be the same view of the config the plan uses.
|
|
10980
|
+
if (this.appliedFleetLevel === null || this.appliedFleetLevel === this.fleetLevelSignature(this.nextFleetConfig())) {
|
|
10981
|
+
return { ok: false, status: 409, error: "no fleet-level change is pending any more" };
|
|
10982
|
+
}
|
|
10983
|
+
const allowance = checkSelfRestartAllowance(this.dataDir);
|
|
10984
|
+
if (!allowance.allowed) {
|
|
10985
|
+
if (allowance.reason === "unreadable") {
|
|
10986
|
+
// Fail closed: with the limit's own state in doubt, "no attempts yet"
|
|
10987
|
+
// is the one reading that must not be assumed.
|
|
10988
|
+
return {
|
|
10989
|
+
ok: false,
|
|
10990
|
+
status: 503,
|
|
10991
|
+
error: "the restart rate-limit file cannot be read — remove self-restart.json from the data dir on the host, or run `agend restart` there",
|
|
10992
|
+
};
|
|
10993
|
+
}
|
|
10994
|
+
return {
|
|
10995
|
+
ok: false,
|
|
10996
|
+
status: 429,
|
|
10997
|
+
error: allowance.reason === "too-soon"
|
|
10998
|
+
? "AgEnD was restarted from Settings very recently"
|
|
10999
|
+
: "too many Settings-triggered restarts in the last hour",
|
|
11000
|
+
retryAfterSeconds: allowance.retryAfterSeconds,
|
|
11001
|
+
};
|
|
11002
|
+
}
|
|
11003
|
+
// Recorded before the notice, not after. If recording keeps failing (a
|
|
11004
|
+
// read-only data dir), posting first would let whoever holds the token spam
|
|
11005
|
+
// the channel with "restarting…" notices for restarts that never happen.
|
|
11006
|
+
// The cost is that a failed announcement still spends an attempt, which is
|
|
11007
|
+
// the right way round for a rate limit.
|
|
11008
|
+
if (!recordSelfRestartAttempt(this.dataDir)) {
|
|
11009
|
+
this.logger.error("Self-restart attempt could not be recorded — refusing to restart unmetered");
|
|
11010
|
+
return { ok: false, status: 503, error: "could not record the restart attempt" };
|
|
11011
|
+
}
|
|
11012
|
+
// Out-of-band notice before the restart, so a panel restart is visible where
|
|
11013
|
+
// the admins are. Refusing when it cannot be posted is the same rule as
|
|
11014
|
+
// refusing when the progress marker cannot be written: no untraceable
|
|
11015
|
+
// restarts.
|
|
11016
|
+
const notice = await this.postSelfRestartNotice();
|
|
11017
|
+
if (!notice) {
|
|
11018
|
+
return {
|
|
11019
|
+
ok: false,
|
|
11020
|
+
status: 409,
|
|
11021
|
+
error: "no chat channel is available to announce the restart — run `agend restart` on the host instead",
|
|
11022
|
+
};
|
|
11023
|
+
}
|
|
11024
|
+
// Consume the row: it moves to running, which is also what lets the next
|
|
11025
|
+
// process settle it (settleAfterRestart only touches non-terminal rows).
|
|
11026
|
+
this.applyJobs.update(jobId, current => {
|
|
11027
|
+
const target = current.targets.find(item => item.target === APPLY_FLEET_TARGET);
|
|
11028
|
+
if (target)
|
|
11029
|
+
target.status = "running";
|
|
11030
|
+
current.restart_key = key;
|
|
11031
|
+
current.deadlineMs = SELF_RESTART_DEADLINE_MS;
|
|
11032
|
+
});
|
|
11033
|
+
this.emitSseEvent("apply_progress", viewOf(this.applyJobs.get(jobId)));
|
|
11034
|
+
const launched = await this.requestFullRestart(notice.adapter, notice.chatId, notice.threadId, notice.messageId)
|
|
11035
|
+
.catch(err => {
|
|
11036
|
+
this.logger.error({ err }, "Settings-triggered self restart failed to launch");
|
|
11037
|
+
return false;
|
|
11038
|
+
});
|
|
11039
|
+
if (!launched) {
|
|
11040
|
+
this.applyJobs.setTargetStatus(jobId, APPLY_FLEET_TARGET, "failed", "restart could not be launched");
|
|
11041
|
+
this.applyJobs.finish(jobId, "restart could not be launched");
|
|
11042
|
+
this.emitSseEvent("apply_progress", viewOf(this.applyJobs.get(jobId)));
|
|
11043
|
+
return { ok: false, status: 409, error: "the restart could not be launched — see fleet.log" };
|
|
11044
|
+
}
|
|
11045
|
+
return { ok: true, jobId };
|
|
11046
|
+
}
|
|
11047
|
+
/** The audit notice, and the message the restart progress will edit. */
|
|
11048
|
+
async postSelfRestartNotice() {
|
|
11049
|
+
const groupId = this.fleetConfig?.channel?.group_id;
|
|
11050
|
+
const adapter = this.adapter;
|
|
11051
|
+
if (!groupId || !adapter)
|
|
11052
|
+
return null;
|
|
11053
|
+
const generalName = this.findGeneralInstance();
|
|
11054
|
+
const rawThreadId = generalName ? this.fleetConfig?.instances[generalName]?.topic_id : undefined;
|
|
11055
|
+
const threadId = rawThreadId != null ? String(rawThreadId) : undefined;
|
|
11056
|
+
try {
|
|
11057
|
+
const sent = await adapter.sendText(String(groupId), t("restart.settings_triggered"), { threadId });
|
|
11058
|
+
if (!sent?.messageId)
|
|
11059
|
+
return null;
|
|
11060
|
+
return { adapter, chatId: sent.chatId, threadId: sent.threadId, messageId: sent.messageId };
|
|
11061
|
+
}
|
|
11062
|
+
catch (err) {
|
|
11063
|
+
this.logger.error({ err }, "Could not announce the Settings-triggered restart — refusing to restart silently");
|
|
11064
|
+
return null;
|
|
11065
|
+
}
|
|
11066
|
+
}
|
|
11067
|
+
/** Jobs outlive this process on purpose; see apply-job.ts. */
|
|
11068
|
+
get applyJobs() {
|
|
11069
|
+
return (this.applyJobStoreCache ??= new ApplyJobStore(this.dataDir, Date.now, this.logger));
|
|
11070
|
+
}
|
|
11071
|
+
/**
|
|
11072
|
+
* What a reconcile is about to do, per target.
|
|
11073
|
+
*
|
|
11074
|
+
* A forecast, not the record: it is built from the same `classifyInstanceChange`
|
|
11075
|
+
* the reconcile decides with, but the rows that end up in the job are the ones
|
|
11076
|
+
* the reconcile reports as it works. A target the forecast missed (a Classic
|
|
11077
|
+
* instance inheriting a changed default) is added when it is first touched.
|
|
11078
|
+
*/
|
|
11079
|
+
planConfigApply() {
|
|
11080
|
+
const rows = [];
|
|
11081
|
+
const nextConfig = this.nextFleetConfig();
|
|
11082
|
+
const next = nextConfig?.instances ?? {};
|
|
11083
|
+
const classicNames = new Set(this.classicChannels?.getAll().map(ch => ch.instanceName) ?? []);
|
|
11084
|
+
for (const [name, config] of Object.entries(next)) {
|
|
11085
|
+
const daemon = this.daemons.get(name);
|
|
11086
|
+
if (!daemon) {
|
|
11087
|
+
rows.push({ target: name, kind: "restart" });
|
|
11088
|
+
continue;
|
|
11089
|
+
}
|
|
11090
|
+
const runtime = daemon.getConfigSnapshot?.();
|
|
11091
|
+
if (!runtime)
|
|
11092
|
+
continue;
|
|
11093
|
+
const change = classifyInstanceChange(runtime, config);
|
|
11094
|
+
if (change !== "none")
|
|
11095
|
+
rows.push({ target: name, kind: change === "restart" ? "restart" : "hot" });
|
|
11096
|
+
}
|
|
11097
|
+
for (const name of this.daemons.keys()) {
|
|
11098
|
+
if (!(name in next) && !classicNames.has(name))
|
|
11099
|
+
rows.push({ target: name, kind: "restart" });
|
|
11100
|
+
}
|
|
11101
|
+
if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== this.fleetLevelSignature(nextConfig)) {
|
|
11102
|
+
rows.push({ target: APPLY_FLEET_TARGET, kind: "restart" });
|
|
11103
|
+
}
|
|
11104
|
+
return rows;
|
|
11105
|
+
}
|
|
11106
|
+
/**
|
|
11107
|
+
* Start (or re-join) a Settings apply.
|
|
11108
|
+
*
|
|
11109
|
+
* The key is the client's. Handing back the existing job for a repeated key is
|
|
11110
|
+
* the whole point: the retry after a lost response must not apply everything a
|
|
11111
|
+
* second time.
|
|
11112
|
+
*/
|
|
11113
|
+
startSettingsApply(key) {
|
|
11114
|
+
// The key is checked first on purpose: a retry of the apply that is running
|
|
11115
|
+
// right now must get its own job back, not "busy".
|
|
11116
|
+
const existing = this.applyJobs.findByKey(key);
|
|
11117
|
+
if (existing)
|
|
11118
|
+
return { job: existing, reused: true };
|
|
11119
|
+
if (this.reconcileInFlight || this.activeApplyJobId) {
|
|
11120
|
+
return { busy: this.activeApplyJobId ? this.applyJobs.get(this.activeApplyJobId) : null };
|
|
11121
|
+
}
|
|
11122
|
+
const job = this.applyJobs.create(key, this.planConfigApply());
|
|
11123
|
+
// Reserved synchronously: the work starts a microtask later, and a second
|
|
11124
|
+
// request arriving in that gap must see the slot taken.
|
|
11125
|
+
this.activeApplyJobId = job.id;
|
|
11126
|
+
// Start after the caller has its answer, so the first thing the page renders
|
|
11127
|
+
// is the whole plan with every row still pending — not a job the reconcile
|
|
11128
|
+
// has already half-finished synchronously.
|
|
11129
|
+
queueMicrotask(() => void this.runSettingsApply(job.id));
|
|
11130
|
+
return { job, reused: false };
|
|
11131
|
+
}
|
|
11132
|
+
async runSettingsApply(jobId) {
|
|
11133
|
+
const emit = () => {
|
|
11134
|
+
const job = this.applyJobs.get(jobId);
|
|
11135
|
+
// An accelerator only: these frames carry no event id, so a client that
|
|
11136
|
+
// reconnects cannot ask for what it missed. GET is the authority.
|
|
11137
|
+
if (job)
|
|
11138
|
+
this.emitSseEvent("apply_progress", viewOf(job));
|
|
11139
|
+
};
|
|
11140
|
+
// Passed in rather than parked on `this`: a shared field would let a second
|
|
11141
|
+
// reconcile redirect this job's reporting into another job's rows.
|
|
11142
|
+
const observer = (target, kind, status, error) => {
|
|
11143
|
+
this.applyJobs.update(jobId, job => {
|
|
11144
|
+
let row = job.targets.find(item => item.target === target);
|
|
11145
|
+
if (!row) {
|
|
11146
|
+
row = { target, kind, status: "pending" };
|
|
11147
|
+
job.targets.push(row);
|
|
11148
|
+
}
|
|
11149
|
+
row.kind = kind;
|
|
11150
|
+
row.status = status;
|
|
11151
|
+
if (error)
|
|
11152
|
+
row.error = error;
|
|
11153
|
+
});
|
|
11154
|
+
emit();
|
|
11155
|
+
};
|
|
11156
|
+
emit();
|
|
11157
|
+
try {
|
|
11158
|
+
const started = this.startExclusiveReconcile(observer);
|
|
11159
|
+
if (!started) {
|
|
11160
|
+
// The slot was reserved before the microtask, so this means a SIGHUP
|
|
11161
|
+
// reconcile started in between. Report it instead of applying twice.
|
|
11162
|
+
this.applyJobs.finish(jobId, "a config reload was already running");
|
|
11163
|
+
return;
|
|
11164
|
+
}
|
|
11165
|
+
const outcome = await started;
|
|
11166
|
+
this.applyJobs.finish(jobId, outcome.rejected);
|
|
11167
|
+
}
|
|
11168
|
+
catch (err) {
|
|
11169
|
+
this.applyJobs.finish(jobId, err instanceof Error ? err.message : String(err));
|
|
11170
|
+
}
|
|
11171
|
+
finally {
|
|
11172
|
+
this.activeApplyJobId = null;
|
|
11173
|
+
emit();
|
|
11174
|
+
}
|
|
10683
11175
|
}
|
|
10684
11176
|
async restartInstances() {
|
|
10685
11177
|
if (!this.configPath) {
|
|
@@ -10972,6 +11464,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10972
11464
|
this.initializeWebAuthTokens();
|
|
10973
11465
|
this.healthServer = createServer((req, res) => {
|
|
10974
11466
|
res.setHeader("Content-Type", "application/json");
|
|
11467
|
+
// No Referer to a tunnel host, an upstream proxy, or any page linked from
|
|
11468
|
+
// the panel — the dashboard URL is itself a credential-bearing address.
|
|
11469
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
11470
|
+
// Authorization now depends on a cookie, so a shared cache (a tunnel, a
|
|
11471
|
+
// corporate proxy) must not serve one visitor's response to another.
|
|
11472
|
+
res.setHeader("Vary", "Cookie");
|
|
10975
11473
|
const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
|
|
10976
11474
|
// Browsers request this automatically and AgEnD does not ship an icon.
|
|
10977
11475
|
// It is neither user data nor an API route, so do not turn the harmless
|
|
@@ -10997,15 +11495,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10997
11495
|
// like the other /view data routes (usage-api.ts rejects non-GET).
|
|
10998
11496
|
}
|
|
10999
11497
|
else {
|
|
11000
|
-
// All other endpoints require a
|
|
11498
|
+
// All other endpoints require a session cookie or an X-Agend-Token
|
|
11499
|
+
// header; a `?token=` in the URL is only redeemed for a cookie on a GET.
|
|
11001
11500
|
// /ui/* will also re-check in web-api.ts, which is harmless.
|
|
11002
11501
|
const parsedUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
11003
|
-
const
|
|
11004
|
-
|
|
11005
|
-
|
|
11006
|
-
|
|
11007
|
-
|
|
11008
|
-
|
|
11502
|
+
const decision = decideWebGate(req, parsedUrl, this.webToken);
|
|
11503
|
+
if (decision.kind === "reject") {
|
|
11504
|
+
res.writeHead(decision.status);
|
|
11505
|
+
res.end(JSON.stringify({ error: decision.message }));
|
|
11506
|
+
return;
|
|
11507
|
+
}
|
|
11508
|
+
if (decision.kind === "exchange") {
|
|
11509
|
+
res.setHeader("Set-Cookie", decision.setCookie);
|
|
11510
|
+
res.setHeader("Location", decision.location);
|
|
11511
|
+
// A cached redirect would replay a Set-Cookie for a rotated token.
|
|
11512
|
+
res.setHeader("Cache-Control", "no-store");
|
|
11513
|
+
res.writeHead(302);
|
|
11514
|
+
// Browsers follow the Location; a script that does not gets told why
|
|
11515
|
+
// its URL token stopped being echoed back as data.
|
|
11516
|
+
res.end(JSON.stringify({ redirect: decision.location }));
|
|
11009
11517
|
return;
|
|
11010
11518
|
}
|
|
11011
11519
|
}
|
|
@@ -11243,8 +11751,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11243
11751
|
this.logger.info({ port }, afterTakeover
|
|
11244
11752
|
? "Health endpoint listening (after takeover)"
|
|
11245
11753
|
: "Health endpoint listening");
|
|
11246
|
-
|
|
11247
|
-
|
|
11754
|
+
// Never the token: fleet.log is readable by anything that can read the
|
|
11755
|
+
// data dir, is copied into bug reports, and is tailed in shared terminals.
|
|
11756
|
+
// `/dashboard` and `agend web` are the ways to get an authorized link.
|
|
11757
|
+
this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /dashboard or `agend web`)");
|
|
11758
|
+
this.logger.info({ url: `http://localhost:${port}/view` }, "Web View available");
|
|
11248
11759
|
};
|
|
11249
11760
|
this.healthServer.on("error", (err) => {
|
|
11250
11761
|
this.healthServerListening = false;
|
|
@@ -11261,8 +11772,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11261
11772
|
if (existsSync(pidPath)) {
|
|
11262
11773
|
const oldPid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
11263
11774
|
if (oldPid && oldPid !== process.pid) {
|
|
11264
|
-
|
|
11265
|
-
|
|
11775
|
+
// fleet.pid is a claim, not proof. A stale or wrong entry points
|
|
11776
|
+
// at whatever now holds that pid, and this used to SIGTERM it —
|
|
11777
|
+
// an unrelated process killed because a port was busy. Confirm
|
|
11778
|
+
// the target really is an AgEnD fleet, and when that cannot be
|
|
11779
|
+
// confirmed, do not signal: not killing costs a dashboard, and
|
|
11780
|
+
// killing costs somebody else's process.
|
|
11781
|
+
const commandLine = readProcessCommandLine(oldPid);
|
|
11782
|
+
if (isFleetStartCommandLine(commandLine)) {
|
|
11783
|
+
process.kill(oldPid, "SIGTERM");
|
|
11784
|
+
this.logger.info({ oldPid }, "Killed old fleet process");
|
|
11785
|
+
}
|
|
11786
|
+
else {
|
|
11787
|
+
this.logger.warn({
|
|
11788
|
+
oldPid,
|
|
11789
|
+
// Truncated: this is an unrelated process's command line, and
|
|
11790
|
+
// fleet.log is copied into bug reports.
|
|
11791
|
+
commandLine: commandLine ? `${commandLine.slice(0, 60)}${commandLine.length > 60 ? "…" : ""}` : "(unreadable)",
|
|
11792
|
+
}, "fleet.pid does not name an AgEnD fleet process — not signalling it");
|
|
11793
|
+
}
|
|
11266
11794
|
}
|
|
11267
11795
|
}
|
|
11268
11796
|
}
|